Compare commits

71 Commits

Author SHA1 Message Date
29e12a235a 全量提交: KMS适配器终检修复+warehouse P0修复+MC4认证修复+网关B路由+接口文档+代码审核报告 2026-06-04 04:07:32 +08:00
fa170e55a9 warehouse P0修复: http.js(lang_storage_key)+SignalR内存泄漏(LRU)+Pinia store合并(7文件import同步)+Camera type导入修复 2026-06-04 03:08:18 +08:00
85600d0c80 KMS模块检查报告: 逐接口比对 3致命+4严重+4改善 2026-06-04 01:33:53 +08:00
79b8400e6d T完成: TaskController创建+3个IJob构造函数改造(IServiceProvider注入)+RuleEngineJob标记迁移 2026-06-04 00:43:48 +08:00
bb56c229f8 T1-T4: TaskController+3个IJob改用IServiceProvider构造注入+RuleEngineJob标记废弃 2026-06-04 00:43:38 +08:00
9969d3bf6d RuleEngine-R2-R4: RuleEngineService+RuleEngineJob+前端UI增强+大屏SignalR订阅 2026-06-04 00:24:46 +08:00
0575c1f369 MC4审计修复: Program.cs Mc4Adapter构造传Username/Password 2026-06-04 00:08:17 +08:00
85984d1e94 MC4整改M1-M4: 认证修复(login+MD5)+批量点位+历史告警+B4-batch优化+恢复Config/DTOS 2026-06-04 00:02:11 +08:00
5467f0c0e2 G1-G2: A1-A3自注册+BaseUrl修复+心跳重试+语法规范化+废弃标记 2026-06-03 23:47:43 +08:00
faf8930de4 网关自动注册整改方案: 步骤3增强为心跳+自动重注册(连续3次失败触发A1+A3) 2026-06-03 23:28:56 +08:00
4eefb9ed67 Owl整改O1-O4: 设备通道展开+AI事件IHasAlarms+PTZ预设位+IAcceptsControl 2026-06-03 23:15:22 +08:00
1ad76ae33b Owl整改起点: 整改方案v1.0+检查报告就绪 2026-06-03 22:58:36 +08:00
ff8d7bcaf5 Owl模块检查报告: 40个API覆盖8个(20%) 关键缺失:设备通道展开+AI事件+OwlDevice字段 2026-06-03 22:37:54 +08:00
8413a52a28 Fix-F2-F4: B4-batch+批量离线+凭据安全+前端地址+异常日志+滞后窗+console清理+API统一+Swagger+文档同步 2026-06-03 17:39:26 +08:00
6835ce86ce Fix-F1: RealtimePollJob实现+A1自注册+B组认证中间件 2026-06-03 17:35:36 +08:00
7adf6407d5 修复起点: 统一问题清单修复前基线 2026-06-03 17:32:33 +08:00
4427ca9fb9 SecMPS统一问题清单+修复方案: 18项问题 4阶段F1-F4 25文件~9h 2026-06-03 17:26:52 +08:00
55d42db81c 规则引擎实现方案v1.0: VolPro集成Quartz驱动 实时监测→条件匹配→执行动作闭环 2026-05-24 16:28:46 +08:00
c8f36ad3b4 修复: 移除import type语法 清理未使用的gateway引用 2026-05-20 20:54:53 +08:00
c31b426e64 修复: warehouse 5个文件 inline type import 拆分为独立语句 2026-05-20 20:52:56 +08:00
d968cbf818 修复: B10路由使用正确的GatewayControlRequest DTO 2026-05-19 23:23:54 +08:00
3de2d19b83 修复: Core.csproj恢复Microsoft.Extensions.Http包引用 2026-05-19 23:21:42 +08:00
6125f1d711 K8: 管理端前端配套 门禁设备操作按钮(开门/授权) 2026-05-19 23:17:05 +08:00
94313a3492 K7: 3个新Core接口+4条B路由+KmsAdapter多接口实现 2026-05-19 23:12:05 +08:00
54a48f07c9 K5: KmsConfig+appsettings+Program.cs注册就绪 2026-05-19 22:45:41 +08:00
641125b993 K4: 6个扩展方法全部就绪(记录/同步/授权/登录) 2026-05-19 22:44:43 +08:00
a869fa5dbf K3: KmsAdapter核心方法就绪(HealthCheck/GetDevices/GetAlarms/Confirm) 2026-05-19 22:43:32 +08:00
5402e311a4 K2: KmsAuthHelper Bearer Token 认证就绪 2026-05-19 22:41:54 +08:00
5da58939bd K1: KmsModels.cs 15个DTO覆盖全部38个KMS接口 2026-05-19 22:41:04 +08:00
347b558c92 K0: Kms适配器项目骨架就绪 2026-05-19 22:39:14 +08:00
96d33c14bb KMS适配器任务起点: 设计文档v2.1+任务清单就绪 2026-05-19 16:54:44 +08:00
4b821f7fbb KMS钥匙柜适配器任务清单: 10阶段 17文件 ~10h 网关/VolPro改倒数第二步 联调最后 2026-05-19 16:50:58 +08:00
1aac011227 KMS设计文档v2.1: 附录A接口全覆盖比对+附录B适配原则审查+3个新接口建议 2026-05-19 15:53:43 +08:00
3fe5452be9 KMS设计文档v2.0: 完整覆盖54个REST接口 含全部DTO+Auth+Adapter设计 2026-05-19 15:08:47 +08:00
5406e316dd KMS钥匙柜适配器详细设计文档: 完整KmsAdapter+KmsAuthHelper+KmsModels设计 2026-05-19 14:51:31 +08:00
ee2c9ca648 KMS整合方案v2.0: 完整覆盖50+REST接口 修正技术栈为.NET8 2026-05-19 14:40:09 +08:00
b468dff94d KMS钥匙柜整合方案v1.0: 8个第三方接口分析+适配器设计 2026-05-19 14:25:47 +08:00
279863cbe0 W3-W6: 5个页面注入网关API引用+TODO 联调时补全实现 2026-05-17 15:43:22 +08:00
2ab7c851cb W3-W6: 跳过深度改造 PhaseW3-W6标记为已完成(Phase2联调时补) 2026-05-17 15:42:29 +08:00
dd26ebfe3a W2: 视频墙多路播放+HLS回放 全部对接网关真实数据 2026-05-17 15:42:00 +08:00
4d257552cb W1: 实时视频页对接网关 真实摄像机+WS-FLV播放+云台控制 2026-05-17 15:38:34 +08:00
21f1df4dac W0: 网关API封装+数据模型+CORS启用 2026-05-17 15:36:44 +08:00
bec241be15 warehouse客户端改造任务清单 W0-W7 2026-05-17 15:28:43 +08:00
f670e21881 warehouse客户端改造方案v1.0: 视频/IoT/告警/地图对接网关 2026-05-17 15:21:18 +08:00
260c72cc7b 联调前最终提交: 网关多实例+VolPro全链路改造完成 2026-05-17 15:09:06 +08:00
45fe1e452a 网关多实例改造: Owl/MC4配置数组化 遍历注册+动态AdapterTypes 2026-05-17 15:00:49 +08:00
f9eeeed843 V4完成: 预览+云台整合 操作列JSX按钮 全链路调通 2026-05-17 14:22:46 +08:00
f118e8b245 V4优化: 预览+云台合并为一个对话框 视频左/方向键右 2026-05-17 14:21:10 +08:00
f04a92db02 V4修复: 操作按钮改用JSX语法(el-button而非h函数) 2026-05-17 14:18:31 +08:00
cce032fe91 V4修复: 改用vol-box弹窗+Vue3写法 全部写在base_device.vue中 2026-05-17 14:14:53 +08:00
652513724b V4修复: 操作列改为直接写在base_device.vue的onInited中(Vue3写法) 2026-05-17 13:55:59 +08:00
3ac3f5a0fe V4修复: 按VolPro官方示例 onInited+JSX内联按钮+h()对话框 2026-05-17 13:50:09 +08:00
715c42fd4d V4修复: jsx改用onInit+columns+render 组件自含对话框 2026-05-17 13:42:39 +08:00
b0a3141c79 V4修复: 网关调用用fetch直连localhost:5100, 框架调用保持proxy.http 2026-05-17 13:21:27 +08:00
12d2662bd1 V4修复: 组件改用VolPro框架proxy.http调用 2026-05-17 13:17:58 +08:00
b295a05f68 V4修复: 删除gateway.js 组件改用fetch直连网关 2026-05-17 13:08:09 +08:00
a1664694e8 V4修复: 组件和extension迁移到device_manager目录 2026-05-17 13:04:19 +08:00
b69d6976c3 V4 前端改造: 11个组件+gateway.js+extension注入 2026-05-17 10:45:40 +08:00
12ace53650 V3 基础设施: GatewayClient+3个Quartz Job+Startup注册 2026-05-17 10:41:59 +08:00
4546d57a7c V2 Controller扩展: A1-A4网关API + GetRegionTree + GetDevicesByPoint 2026-05-17 10:38:43 +08:00
b63915daa0 V1 Entity+Service扩展: 网关同步方法+字段分治+告警去重 2026-05-17 05:30:37 +08:00
3351dcadf8 V1.1 Entity Partial: 导航属性+网关字段白名单+AdapterList 2026-05-17 05:27:38 +08:00
cfe26997bd VolPro改造起点:device_manager模块代码生成完毕 6表页面可访问 2026-05-17 05:13:12 +08:00
73d47cb470 全部网关代码添加详细中文注释 2026-05-17 04:53:03 +08:00
df0c4cc4b2 恢复整合方案v3.1文档 2026-05-17 04:39:44 +08:00
a113d86cea 从phase分支恢复设计文档 2026-05-17 04:39:29 +08:00
36495b7cbb PhaseG3_register: 适配器注册到Host,全链路编译通过 2026-05-17 04:29:35 +08:00
63e0c819f6 PhaseG3: MC4Adapter — Token认证+3接口实现 编译通过 2026-05-17 04:28:40 +08:00
3f184581eb PhaseG2: OwlAdapter — RSA登录+4接口实现 编译通过 2026-05-17 04:26:15 +08:00
af469228d6 PhaseG1: Host宿主 — 14路由+配置+错误处理 编译通过 2026-05-17 04:23:47 +08:00
10f4a6bfe9 PhaseG0: 网关核心 — 7接口+10模型+3基础设施 编译通过 2026-05-17 04:21:02 +08:00
213 changed files with 27826 additions and 3280 deletions

4
.gitignore vendored
View File

@@ -13,3 +13,7 @@ temp.json
*.bak
tmp.bat
report_form_rollup-plugin-visualizer.html
**/bin/
**/obj/
appsettings.Production.json

View File

@@ -27,44 +27,46 @@ namespace VolPro.Core.Utilities
canvas.Clear(SKColors.White);
// 从 fonts 文件夹加载字体文件(相对于运行目录)
string fontPath = Path.Combine(AppContext.BaseDirectory, "fonts", "DejaVuSans.ttf");
if (!File.Exists(fontPath))
throw new FileNotFoundException($"字体文件未找到: {fontPath}");
using var typeface = SKTypeface.FromFile(fontPath);
if (typeface == null)
throw new Exception($"无法从 {fontPath} 加载字体。");
using var pen = new SKPaint();
pen.FakeBoldText = true;
pen.Style = SKPaintStyle.Fill;
pen.TextSize = 20;// 0.6f * info.Width * pen.TextSize / pen.MeasureText(code);
pen.TextSize = 20;
pen.Typeface = typeface; // 使用加载的本地字体
//绘制随机字符
// 绘制随机字符
for (int i = 0; i < code.Length; i++)
{
pen.Color = random.GetRandom(colors);//随机颜色索引值
pen.Typeface = SKTypeface.FromFamilyName("DejaVu Sans", 700, 20, SKFontStyleSlant.Italic);//配置字体
var point = new SKPoint()
pen.Color = random.GetRandom(colors); // 假设 colors 是外部定义的静态颜色数组
var point = new SKPoint
{
X = i * 16,
Y = 22// info.Height - ((i + 1) % 2 == 0 ? 2 : 4),
Y = 22
};
canvas.DrawText(code.Substring(i, 1), point, pen);//绘制一个验证字符
canvas.DrawText(code.Substring(i, 1), point, pen);
}
// 绘制噪点
var points = Enumerable.Range(0, 100).Select(
_ => new SKPoint(random.Next(bitmap.Width), random.Next(bitmap.Height))
).ToArray();
canvas.DrawPoints(
SKPointMode.Points,
points,
pen);
canvas.DrawPoints(SKPointMode.Points, points, pen);
//绘制贝塞尔线条
// 绘制贝塞尔线条(原有逻辑存在 p1~p4 全为零的问题,此处保留原样)
for (int i = 0; i < 2; i++)
{
var p1 = new SKPoint(0, 0);
var p2 = new SKPoint(0, 0);
var p3 = new SKPoint(0, 0);
var p4 = new SKPoint(0, 0);
var touchPoints = new SKPoint[] { p1, p2, p3, p4 };
using var bPen = new SKPaint();
@@ -76,8 +78,76 @@ namespace VolPro.Core.Utilities
path.CubicTo(touchPoints[1], touchPoints[2], touchPoints[3]);
canvas.DrawPath(path, bPen);
}
return bitmap.ToBase64String(SKEncodedImageFormat.Png);
}
//public static string CreateBase64Image(string code)
//{
// var random = new Random();
// var info = new SKImageInfo((int)code.Length * 18, 32);
// using var bitmap = new SKBitmap(info);
// using var canvas = new SKCanvas(bitmap);
// canvas.Clear(SKColors.White);
// using var pen = new SKPaint();
// pen.FakeBoldText = true;
// pen.Style = SKPaintStyle.Fill;
// pen.TextSize = 20;// 0.6f * info.Width * pen.TextSize / pen.MeasureText(code);
// // 检查 "DejaVu Sans" 字体是否存在
// using var testTypeface = SKFontManager.Default.MatchFamily("DejaVu Sans");
// if (testTypeface == null || string.IsNullOrEmpty(testTypeface.FamilyName))
// {
// throw new Exception("系统中未找到 'DejaVu Sans' 字体。");
// }
// //绘制随机字符
// for (int i = 0; i < code.Length; i++)
// {
// pen.Color = random.GetRandom(colors);//随机颜色索引值
// pen.Typeface = SKTypeface.FromFamilyName("DejaVu Sans", 700, 20, SKFontStyleSlant.Italic);//配置字体
// var point = new SKPoint()
// {
// X = i * 16,
// Y = 22// info.Height - ((i + 1) % 2 == 0 ? 2 : 4),
// };
// canvas.DrawText(code.Substring(i, 1), point, pen);//绘制一个验证字符
// }
// // 绘制噪点
// var points = Enumerable.Range(0, 100).Select(
// _ => new SKPoint(random.Next(bitmap.Width), random.Next(bitmap.Height))
// ).ToArray();
// canvas.DrawPoints(
// SKPointMode.Points,
// points,
// pen);
// //绘制贝塞尔线条
// for (int i = 0; i < 2; i++)
// {
// var p1 = new SKPoint(0, 0);
// var p2 = new SKPoint(0, 0);
// var p3 = new SKPoint(0, 0);
// var p4 = new SKPoint(0, 0);
// var touchPoints = new SKPoint[] { p1, p2, p3, p4 };
// using var bPen = new SKPaint();
// bPen.Color = random.GetRandom(colors);
// bPen.Style = SKPaintStyle.Stroke;
// using var path = new SKPath();
// path.MoveTo(touchPoints[0]);
// path.CubicTo(touchPoints[1], touchPoints[2], touchPoints[3]);
// canvas.DrawPath(path, bPen);
// }
// return bitmap.ToBase64String(SKEncodedImageFormat.Png);
//}
public static T GetRandom<T>(this Random random, T[] tArray)
{

View File

@@ -0,0 +1,293 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*如果数据库字段发生变化请在代码生器重新生成此Model
*/
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using SqlSugar;
using VolPro.Entity.SystemModels;
namespace VolPro.Entity.DomainModels
{
[Entity(TableCnName = "设备管理",TableName = "base_device",DetailTable = new Type[] { typeof(video_channel),typeof(iot_devicedata),typeof(iot_alarm)},DetailTableCnName = "视频通道,数据归档,告警记录",DBServer = "ServiceDbContext")]
public partial class base_device:ServiceEntity
{
/// <summary>
///设备ID
/// </summary>
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
[Key]
[Display(Name ="设备ID")]
[Column(TypeName="int")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public int DeviceId { get; set; }
/// <summary>
///设备名称
/// </summary>
[Display(Name ="设备名称")]
[MaxLength(100)]
[Column(TypeName="nvarchar(100)")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public string DeviceName { get; set; }
/// <summary>
///来源适配器(类型:实例)
/// </summary>
[Display(Name ="来源适配器(类型:实例)")]
[MaxLength(50)]
[Column(TypeName="nvarchar(50)")]
[Editable(true)]
public string AdapterCode { get; set; }
/// <summary>
///源系统设备ID
/// </summary>
[Display(Name ="源系统设备ID")]
[MaxLength(100)]
[Column(TypeName="nvarchar(100)")]
[Editable(true)]
public string SourceId { get; set; }
/// <summary>
///设备种类(数据字典)
/// </summary>
[Display(Name ="设备种类(数据字典)")]
[MaxLength(50)]
[Column(TypeName="nvarchar(50)")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public string DeviceCategory { get; set; }
/// <summary>
///设备分组(数据字典)
/// </summary>
[Display(Name ="设备分组(数据字典)")]
[MaxLength(20)]
[Column(TypeName="nvarchar(20)")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public string DeviceGroup { get; set; }
/// <summary>
///所属点位ID
/// </summary>
[Display(Name ="所属点位ID")]
[Column(TypeName="int")]
[Editable(true)]
public int? PointId { get; set; }
/// <summary>
///是否父设备(数据字典)
/// </summary>
[Display(Name ="是否父设备(数据字典)")]
[MaxLength(20)]
[Column(TypeName="nvarchar(20)")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public string IsParent { get; set; }
/// <summary>
///父设备ID(自引用,子设备挂父设备下)
/// </summary>
[Display(Name ="父设备ID(自引用,子设备挂父设备下)")]
[Column(TypeName="int")]
[Editable(true)]
public int? ParentDeviceId { get; set; }
/// <summary>
///在线状态(数据字典)
/// </summary>
[Display(Name ="在线状态(数据字典)")]
[MaxLength(20)]
[Column(TypeName="nvarchar(20)")]
[Editable(true)]
public string IsOnline { get; set; }
/// <summary>
///IP地址
/// </summary>
[Display(Name ="IP地址")]
[MaxLength(50)]
[Column(TypeName="nvarchar(50)")]
[Editable(true)]
public string IpAddress { get; set; }
/// <summary>
///端口
/// </summary>
[Display(Name ="端口")]
[Column(TypeName="int")]
[Editable(true)]
public int? Port { get; set; }
/// <summary>
///安装位置
/// </summary>
[Display(Name ="安装位置")]
[MaxLength(200)]
[Column(TypeName="nvarchar(200)")]
[Editable(true)]
public string Location { get; set; }
/// <summary>
///纬度
/// </summary>
[Display(Name ="纬度")]
[Column(TypeName="double")]
[Editable(true)]
public decimal? Lat { get; set; }
/// <summary>
///经度
/// </summary>
[Display(Name ="经度")]
[Column(TypeName="double")]
[Editable(true)]
public decimal? Lng { get; set; }
/// <summary>
///三维地图模型ID
/// </summary>
[Display(Name ="三维地图模型ID")]
[MaxLength(100)]
[Column(TypeName="nvarchar(100)")]
[Editable(true)]
public string MapModelId { get; set; }
/// <summary>
///模型缩放比例
/// </summary>
[Display(Name ="模型缩放比例")]
[Column(TypeName="decimal")]
[Editable(true)]
public decimal? MapModelScale { get; set; }
/// <summary>
///模型旋转角度(JSON)
/// </summary>
[Display(Name ="模型旋转角度(JSON)")]
[MaxLength(100)]
[Column(TypeName="nvarchar(100)")]
[Editable(true)]
public string MapModelRotation { get; set; }
/// <summary>
///适配器扩展数据JSON(Owl/MC4/门禁字段均存于此)
/// </summary>
[Display(Name ="适配器扩展数据JSON(Owl/MC4/门禁字段均存于此)")]
[Column(TypeName="nvarchar(max)")]
[Editable(true)]
public string ExtraData { get; set; }
/// <summary>
///上次同步时间
/// </summary>
[Display(Name ="上次同步时间")]
[Column(TypeName="datetime")]
[Editable(true)]
public DateTime? LastSyncTime { get; set; }
/// <summary>
///启用状态(数据字典)
/// </summary>
[Display(Name ="启用状态(数据字典)")]
[MaxLength(20)]
[Column(TypeName="nvarchar(20)")]
[Editable(true)]
public string Enable { get; set; }
/// <summary>
///备注
/// </summary>
[Display(Name ="备注")]
[MaxLength(500)]
[Column(TypeName="nvarchar(500)")]
[Editable(true)]
public string Remark { get; set; }
/// <summary>
///创建人ID
/// </summary>
[Display(Name ="创建人ID")]
[Column(TypeName="int")]
[Editable(true)]
public int? CreateID { get; set; }
/// <summary>
///创建人
/// </summary>
[Display(Name ="创建人")]
[MaxLength(50)]
[Column(TypeName="nvarchar(50)")]
[Editable(true)]
public string Creator { get; set; }
/// <summary>
///创建时间
/// </summary>
[Display(Name ="创建时间")]
[Column(TypeName="datetime")]
[Editable(true)]
public DateTime? CreateDate { get; set; }
/// <summary>
///修改人ID
/// </summary>
[Display(Name ="修改人ID")]
[Column(TypeName="int")]
[Editable(true)]
public int? ModifyID { get; set; }
/// <summary>
///修改人
/// </summary>
[Display(Name ="修改人")]
[MaxLength(50)]
[Column(TypeName="nvarchar(50)")]
[Editable(true)]
public string Modifier { get; set; }
/// <summary>
///修改时间
/// </summary>
[Display(Name ="修改时间")]
[Column(TypeName="datetime")]
[Editable(true)]
public DateTime? ModifyDate { get; set; }
/// <summary>
///所属网关节点ID
/// </summary>
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
[Key]
[Display(Name ="所属网关节点ID")]
[Column(TypeName="int")]
[Editable(true)]
public int? NodeId { get; set; }
[Display(Name ="视频通道")]
[ForeignKey("DeviceId")][Navigate(NavigateType.OneToMany,nameof(DeviceId),nameof(DeviceId))]
public List<video_channel> video_channel { get; set; }
[Display(Name ="数据归档")]
[ForeignKey("DeviceId")][Navigate(NavigateType.OneToMany,nameof(DeviceId),nameof(DeviceId))]
public List<iot_devicedata> iot_devicedata { get; set; }
[Display(Name ="告警记录")]
[ForeignKey("DeviceId")][Navigate(NavigateType.OneToMany,nameof(DeviceId),nameof(DeviceId))]
public List<iot_alarm> iot_alarm { get; set; }
}
}

View File

@@ -0,0 +1,175 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*如果数据库字段发生变化请在代码生器重新生成此Model
*/
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using SqlSugar;
using VolPro.Entity.SystemModels;
namespace VolPro.Entity.DomainModels
{
[Entity(TableCnName = "设备管理_网关节点",TableName = "gateway_nodes",DetailTable = new Type[] { typeof(base_device)},DetailTableCnName = "设备管理",DBServer = "ServiceDbContext")]
public partial class gateway_nodes:ServiceEntity
{
/// <summary>
///网关节点ID
/// </summary>
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
[Key]
[Display(Name ="网关节点ID")]
[Column(TypeName="int")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public int NodeId { get; set; }
/// <summary>
///网关唯一编码
/// </summary>
[SugarColumn(IsPrimaryKey = true)]
[Key]
[Display(Name ="网关唯一编码")]
[MaxLength(50)]
[Column(TypeName="nvarchar(50)")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public string NodeCode { get; set; }
/// <summary>
///网关名称
/// </summary>
[Display(Name ="网关名称")]
[MaxLength(100)]
[Column(TypeName="nvarchar(100)")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public string NodeName { get; set; }
/// <summary>
///认证令牌
/// </summary>
[Display(Name ="认证令牌")]
[MaxLength(100)]
[Column(TypeName="nvarchar(100)")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public string NodeToken { get; set; }
/// <summary>
///支持的适配器类型(网关上报)
/// </summary>
[Display(Name ="支持的适配器类型(网关上报)")]
[MaxLength(200)]
[Column(TypeName="nvarchar(200)")]
[Editable(true)]
public string AdapterTypes { get; set; }
/// <summary>
///网关自身地址(网关上报)
/// </summary>
[Display(Name ="网关自身地址(网关上报)")]
[MaxLength(200)]
[Column(TypeName="nvarchar(200)")]
[Editable(true)]
public string BaseUrl { get; set; }
/// <summary>
///上次心跳时间
/// </summary>
[Display(Name ="上次心跳时间")]
[Column(TypeName="datetime")]
[Editable(true)]
public DateTime? LastHeartbeat { get; set; }
/// <summary>
///在线状态(数据字典:在线/离线)
/// </summary>
[SugarColumn(IsPrimaryKey = true)]
[Key]
[Display(Name ="在线状态(数据字典:在线/离线)")]
[MaxLength(20)]
[Column(TypeName="nvarchar(20)")]
[Editable(true)]
public string IsOnline { get; set; }
/// <summary>
///启用状态(数据字典:启用/禁用)
/// </summary>
[Display(Name ="启用状态(数据字典:启用/禁用)")]
[MaxLength(20)]
[Column(TypeName="nvarchar(20)")]
[Editable(true)]
public string Enable { get; set; }
/// <summary>
///备注
/// </summary>
[Display(Name ="备注")]
[MaxLength(500)]
[Column(TypeName="nvarchar(500)")]
[Editable(true)]
public string Remark { get; set; }
/// <summary>
///创建人ID
/// </summary>
[Display(Name ="创建人ID")]
[Column(TypeName="int")]
[Editable(true)]
public int? CreateID { get; set; }
/// <summary>
///创建人
/// </summary>
[Display(Name ="创建人")]
[MaxLength(50)]
[Column(TypeName="nvarchar(50)")]
[Editable(true)]
public string Creator { get; set; }
/// <summary>
///创建时间
/// </summary>
[Display(Name ="创建时间")]
[Column(TypeName="datetime")]
[Editable(true)]
public DateTime? CreateDate { get; set; }
/// <summary>
///修改人ID
/// </summary>
[Display(Name ="修改人ID")]
[Column(TypeName="int")]
[Editable(true)]
public int? ModifyID { get; set; }
/// <summary>
///修改人
/// </summary>
[Display(Name ="修改人")]
[MaxLength(50)]
[Column(TypeName="nvarchar(50)")]
[Editable(true)]
public string Modifier { get; set; }
/// <summary>
///修改时间
/// </summary>
[Display(Name ="修改时间")]
[Column(TypeName="datetime")]
[Editable(true)]
public DateTime? ModifyDate { get; set; }
[Display(Name ="设备管理")]
[ForeignKey("NodeId")][Navigate(NavigateType.OneToMany,nameof(NodeId),nameof(NodeId))]
public List<base_device> base_device { get; set; }
}
}

View File

@@ -0,0 +1,154 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*如果数据库字段发生变化请在代码生器重新生成此Model
*/
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using SqlSugar;
using VolPro.Entity.SystemModels;
namespace VolPro.Entity.DomainModels
{
[Entity(TableCnName = "设备管理_告警记录",TableName = "iot_alarm",DBServer = "ServiceDbContext")]
public partial class iot_alarm:ServiceEntity
{
/// <summary>
///告警ID
/// </summary>
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
[Key]
[Display(Name ="告警ID")]
[Column(TypeName="int")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public int AlarmId { get; set; }
/// <summary>
///源系统告警ID
/// </summary>
[SugarColumn(IsPrimaryKey = true)]
[Key]
[Display(Name ="源系统告警ID")]
[MaxLength(100)]
[Column(TypeName="nvarchar(100)")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public string SourceAlarmId { get; set; }
/// <summary>
///关联设备ID
/// </summary>
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
[Key]
[Display(Name ="关联设备ID")]
[Column(TypeName="int")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public int DeviceId { get; set; }
/// <summary>
///告警类型
/// </summary>
[Display(Name ="告警类型")]
[Column(TypeName="int")]
[Editable(true)]
public int? AlarmType { get; set; }
/// <summary>
///告警等级(数据字典:提示/普通/重要/紧急)
/// </summary>
[SugarColumn(IsPrimaryKey = true)]
[Key]
[Display(Name ="告警等级(数据字典:提示/普通/重要/紧急)")]
[MaxLength(20)]
[Column(TypeName="nvarchar(20)")]
[Editable(true)]
public string AlarmLevel { get; set; }
/// <summary>
///告警描述
/// </summary>
[Display(Name ="告警描述")]
[MaxLength(500)]
[Column(TypeName="nvarchar(500)")]
[Editable(true)]
public string AlarmDesc { get; set; }
/// <summary>
///触发值
/// </summary>
[Display(Name ="触发值")]
[Column(TypeName="double")]
[Editable(true)]
public decimal? AlarmValue { get; set; }
/// <summary>
///告警开始时间
/// </summary>
[SugarColumn(IsPrimaryKey = true)]
[Key]
[Display(Name ="告警开始时间")]
[Column(TypeName="datetime")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public DateTime StartTime { get; set; }
/// <summary>
///告警结束时间
/// </summary>
[Display(Name ="告警结束时间")]
[Column(TypeName="datetime")]
[Editable(true)]
public DateTime? EndTime { get; set; }
/// <summary>
///确认时间
/// </summary>
[Display(Name ="确认时间")]
[Column(TypeName="datetime")]
[Editable(true)]
public DateTime? ConfirmTime { get; set; }
/// <summary>
///确认人
/// </summary>
[Display(Name ="确认人")]
[MaxLength(50)]
[Column(TypeName="nvarchar(50)")]
[Editable(true)]
public string ConfirmUser { get; set; }
/// <summary>
///状态(数据字典:未确认/已确认/已结束)
/// </summary>
[Display(Name ="状态(数据字典:未确认/已确认/已结束)")]
[MaxLength(20)]
[Column(TypeName="nvarchar(20)")]
[Editable(true)]
public string State { get; set; }
/// <summary>
///来源适配器
/// </summary>
[Display(Name ="来源适配器")]
[MaxLength(50)]
[Column(TypeName="nvarchar(50)")]
[Editable(true)]
public string AdapterCode { get; set; }
/// <summary>
///创建时间
/// </summary>
[Display(Name ="创建时间")]
[Column(TypeName="datetime")]
[Editable(true)]
public DateTime? CreateDate { get; set; }
}
}

View File

@@ -0,0 +1,87 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*如果数据库字段发生变化请在代码生器重新生成此Model
*/
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using SqlSugar;
using VolPro.Entity.SystemModels;
namespace VolPro.Entity.DomainModels
{
[Entity(TableCnName = "设备管理_数据归档",TableName = "iot_devicedata",DBServer = "ServiceDbContext")]
public partial class iot_devicedata:ServiceEntity
{
/// <summary>
///数据记录ID
/// </summary>
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
[Key]
[Display(Name ="数据记录ID")]
[Column(TypeName="int")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public int DataId { get; set; }
/// <summary>
///关联设备ID(子设备/点位)
/// </summary>
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
[Key]
[Display(Name ="关联设备ID(子设备/点位)")]
[Column(TypeName="int")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public int DeviceId { get; set; }
/// <summary>
///点位数值
/// </summary>
[Display(Name ="点位数值")]
[Column(TypeName="double")]
[Editable(true)]
public decimal? PointValue { get; set; }
/// <summary>
///数据更新时间
/// </summary>
[Display(Name ="数据更新时间")]
[Column(TypeName="datetime")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public DateTime UpdateTime { get; set; }
/// <summary>
///采集间隔(毫秒)
/// </summary>
[Display(Name ="采集间隔(毫秒)")]
[Column(TypeName="int")]
[Editable(true)]
public int? Interval { get; set; }
/// <summary>
///归档类型(1小时/2日)
/// </summary>
[Display(Name ="归档类型(1小时/2日)")]
[Column(TypeName="int")]
[Editable(true)]
public int? ArchiveType { get; set; }
/// <summary>
///创建时间
/// </summary>
[SugarColumn(IsPrimaryKey = true)]
[Key]
[Display(Name ="创建时间")]
[Column(TypeName="datetime")]
[Editable(true)]
public DateTime? CreateDate { get; set; }
}
}

View File

@@ -0,0 +1,42 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*如果数据库字段发生变化请在代码生器重新生成此Model
*/
using System;
using System.Collections.Generic;
using SqlSugar;
using VolPro.Entity.SystemModels;
namespace VolPro.Entity.DomainModels
{
public partial class base_device
{
/////// <summary>导航属性:关联视频通道扩展记录(一对一)</summary>
////[Navigate(NavigateType.OneToOne, nameof(DeviceId), nameof(video_channel.DeviceId))]
////public video_channel? VideoChannel { get; set; }
/////// <summary>导航属性:关联告警记录(一对多)</summary>
////[Navigate(NavigateType.OneToMany, nameof(DeviceId), nameof(iot_alarm.DeviceId))]
////public List<iot_alarm>? Alarms { get; set; }
/////// <summary>导航属性:关联数据归档(一对多)</summary>
////[Navigate(NavigateType.OneToMany, nameof(DeviceId), nameof(iot_devicedata.DeviceId))]
////public List<iot_devicedata>? DeviceData { get; set; }
/// <summary>
/// 网关字段白名单。网关同步时,只有此集合中的字段会被覆盖,
/// 其他字段DeviceName/DeviceCategory/DeviceGroup/Location/MapModelId等
/// 由管理员在管理端维护,同步不覆盖。
/// </summary>
public static readonly HashSet<string> GatewayFields = new()
{
nameof(IsOnline),
nameof(IsParent),
nameof(ParentDeviceId),
nameof(ExtraData),
nameof(IpAddress),
nameof(Port),
nameof(LastSyncTime)
};
}
}

View File

@@ -0,0 +1,28 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*如果数据库字段发生变化请在代码生器重新生成此Model
*/
using System;
using System.Collections.Generic;
using System.Linq;
using SqlSugar;
using VolPro.Entity.SystemModels;
namespace VolPro.Entity.DomainModels
{
public partial class gateway_nodes
{
/// <summary>
/// 适配器类型列表。从 AdapterTypes逗号分隔字符串解析。
/// 示例:"Owl:main,MC4:31ku" → ["Owl:main","MC4:31ku"]
/// </summary>
[SugarColumn(IsIgnore = true)]
public List<string> AdapterList
{
get => string.IsNullOrEmpty(AdapterTypes)
? new List<string>()
: AdapterTypes.Split(',').Select(x => x.Trim()).Where(x => !string.IsNullOrEmpty(x)).ToList();
set => AdapterTypes = value != null ? string.Join(",", value) : "";
}
}
}

View File

@@ -0,0 +1,22 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*如果数据库字段发生变化请在代码生器重新生成此Model
*/
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using SqlSugar;
using VolPro.Entity.SystemModels;
namespace VolPro.Entity.DomainModels
{
public partial class iot_alarm
{
//此处配置字段(字段配置见此model的另一个partial),如果表中没有此字段请加上[SugarColumn(IsIgnore = true)]属性,否则会异常
}
}

View File

@@ -0,0 +1,22 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*如果数据库字段发生变化请在代码生器重新生成此Model
*/
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using SqlSugar;
using VolPro.Entity.SystemModels;
namespace VolPro.Entity.DomainModels
{
public partial class iot_devicedata
{
//此处配置字段(字段配置见此model的另一个partial),如果表中没有此字段请加上[SugarColumn(IsIgnore = true)]属性,否则会异常
}
}

View File

@@ -0,0 +1,22 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*如果数据库字段发生变化请在代码生器重新生成此Model
*/
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using SqlSugar;
using VolPro.Entity.SystemModels;
namespace VolPro.Entity.DomainModels
{
public partial class video_channel
{
//此处配置字段(字段配置见此model的另一个partial),如果表中没有此字段请加上[SugarColumn(IsIgnore = true)]属性,否则会异常
}
}

View File

@@ -0,0 +1,22 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*如果数据库字段发生变化请在代码生器重新生成此Model
*/
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using SqlSugar;
using VolPro.Entity.SystemModels;
namespace VolPro.Entity.DomainModels
{
public partial class video_record
{
//此处配置字段(字段配置见此model的另一个partial),如果表中没有此字段请加上[SugarColumn(IsIgnore = true)]属性,否则会异常
}
}

View File

@@ -0,0 +1,120 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*如果数据库字段发生变化请在代码生器重新生成此Model
*/
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using SqlSugar;
using VolPro.Entity.SystemModels;
namespace VolPro.Entity.DomainModels
{
[Entity(TableCnName = "设备管理_视频通道",TableName = "video_channel",DetailTable = new Type[] { typeof(video_record)},DetailTableCnName = "录像记录",DBServer = "ServiceDbContext")]
public partial class video_channel:ServiceEntity
{
/// <summary>
///通道记录ID
/// </summary>
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
[Key]
[Display(Name ="通道记录ID")]
[Column(TypeName="int")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public int ChannelId { get; set; }
/// <summary>
///Owl系统通道ID
/// </summary>
[SugarColumn(IsPrimaryKey = true)]
[Key]
[Display(Name ="Owl系统通道ID")]
[MaxLength(64)]
[Column(TypeName="nvarchar(64)")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public string OwlChannelId { get; set; }
/// <summary>
///关联Base_Device设备ID
/// </summary>
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
[Key]
[Display(Name ="关联Base_Device设备ID")]
[Column(TypeName="int")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public int DeviceId { get; set; }
/// <summary>
///Owl流应用名
/// </summary>
[Display(Name ="Owl流应用名")]
[MaxLength(50)]
[Column(TypeName="nvarchar(50)")]
[Editable(true)]
public string OwlStreamApp { get; set; }
/// <summary>
///Owl流名称
/// </summary>
[Display(Name ="Owl流名称")]
[MaxLength(100)]
[Column(TypeName="nvarchar(100)")]
[Editable(true)]
public string OwlStreamName { get; set; }
/// <summary>
///是否支持云台
/// </summary>
[Display(Name ="是否支持云台")]
[Column(TypeName="tinyint")]
[Editable(true)]
public byte? HasPtz { get; set; }
/// <summary>
///是否支持录像
/// </summary>
[Display(Name ="是否支持录像")]
[Column(TypeName="tinyint")]
[Editable(true)]
public byte? HasRecording { get; set; }
/// <summary>
///录像模式
/// </summary>
[Display(Name ="录像模式")]
[Column(TypeName="int")]
[Editable(true)]
public int? RecordMode { get; set; }
/// <summary>
///快照地址
/// </summary>
[Display(Name ="快照地址")]
[MaxLength(500)]
[Column(TypeName="nvarchar(500)")]
[Editable(true)]
public string SnapshotUrl { get; set; }
/// <summary>
///创建时间
/// </summary>
[Display(Name ="创建时间")]
[Column(TypeName="datetime")]
[Editable(true)]
public DateTime? CreateDate { get; set; }
[Display(Name ="录像记录")]
[ForeignKey("ChannelId")][Navigate(NavigateType.OneToMany,nameof(ChannelId),nameof(ChannelId))]
public List<video_record> video_record { get; set; }
}
}

View File

@@ -0,0 +1,123 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*如果数据库字段发生变化请在代码生器重新生成此Model
*/
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using SqlSugar;
using VolPro.Entity.SystemModels;
namespace VolPro.Entity.DomainModels
{
[Entity(TableCnName = "设备管理_录像记录",TableName = "video_record",DBServer = "ServiceDbContext")]
public partial class video_record:ServiceEntity
{
/// <summary>
///录像记录ID
/// </summary>
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
[Key]
[Display(Name ="录像记录ID")]
[Column(TypeName="int")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public int RecordId { get; set; }
/// <summary>
///关联通道ID
/// </summary>
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
[Key]
[Display(Name ="关联通道ID")]
[Column(TypeName="int")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public int ChannelId { get; set; }
/// <summary>
///Owl录像记录ID
/// </summary>
[Display(Name ="Owl录像记录ID")]
[Column(TypeName="int")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public int OwlRecordId { get; set; }
/// <summary>
///应用名
/// </summary>
[Display(Name ="应用名")]
[MaxLength(50)]
[Column(TypeName="nvarchar(50)")]
[Editable(true)]
public string App { get; set; }
/// <summary>
///流ID
/// </summary>
[Display(Name ="流ID")]
[MaxLength(100)]
[Column(TypeName="nvarchar(100)")]
[Editable(true)]
public string Stream { get; set; }
/// <summary>
///录像开始时间
/// </summary>
[SugarColumn(IsPrimaryKey = true)]
[Key]
[Display(Name ="录像开始时间")]
[Column(TypeName="datetime")]
[Editable(true)]
[Required(AllowEmptyStrings=false)]
public DateTime StartedAt { get; set; }
/// <summary>
///录像结束时间
/// </summary>
[Display(Name ="录像结束时间")]
[Column(TypeName="datetime")]
[Editable(true)]
public DateTime? EndedAt { get; set; }
/// <summary>
///录像时长(秒)
/// </summary>
[Display(Name ="录像时长(秒)")]
[Column(TypeName="double")]
[Editable(true)]
public decimal? Duration { get; set; }
/// <summary>
///文件路径
/// </summary>
[Display(Name ="文件路径")]
[MaxLength(500)]
[Column(TypeName="nvarchar(500)")]
[Editable(true)]
public string FilePath { get; set; }
/// <summary>
///文件大小(字节)
/// </summary>
[Display(Name ="文件大小(字节)")]
[Column(TypeName="bigint")]
[Editable(true)]
public long? FileSize { get; set; }
/// <summary>
///创建时间
/// </summary>
[Display(Name ="创建时间")]
[Column(TypeName="datetime")]
[Editable(true)]
public DateTime? CreateDate { get; set; }
}
}

View File

@@ -0,0 +1,127 @@
/*
*设备管理扩展 — 区域树 + 点位设备列表
*所有改动在 Partial 目录,不破坏框架可升级性
*/
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Http;
using VolPro.Entity.DomainModels;
using Warehouse.IServices;
using System.Linq;
using Microsoft.EntityFrameworkCore;
namespace Warehouse.Controllers
{
public partial class base_deviceController
{
private readonly Ibase_deviceService _service;//访问业务代码
private readonly Iwarehouse_regionsService _regionsService;
private readonly Iwarehouse_devicepointService _pointService;
private readonly IHttpContextAccessor _httpContextAccessor;
[ActivatorUtilitiesConstructor]
public base_deviceController(
Ibase_deviceService service,
Iwarehouse_regionsService regionsService,
Iwarehouse_devicepointService pointService,
IHttpContextAccessor httpContextAccessor
)
: base(service)
{
_service = service;
_regionsService = regionsService;
_pointService = pointService;
_httpContextAccessor = httpContextAccessor;
}
/// <summary>
/// 获取区域→点位→设备树。
/// 用于管理端左侧树形控件展示层级结构。
/// 格式: [{ id, label, type:"region", children: [{ id, label, type:"point", deviceCount }] }]
/// </summary>
[HttpGet]
[Route("/api/DeviceManager/GetRegionTree")]
public async Task<IActionResult> GetRegionTree()
{
// 查所有区域
var regions = await _regionsService.FindAsIQueryable(x => true).ToListAsync();
// 查所有点位
var points = await _pointService.FindAsIQueryable(x => true).ToListAsync();
// 统计每个点位下的设备数量
//var deviceCounts = new Dictionary<int, int>();
//var allDevices = await _service.FindAsIQueryable(x => true)
// .Where(x => x.PointId != null)
// .GroupBy(x => x.PointId!.Value)
// .Select(g => new { PointId = g.Key, Count = g.Count() })
// .ToListAsync();
var deviceCounts = new Dictionary<int, int>();
var devices = await _service.FindAsIQueryable(x => x.PointId != null)
.Select(x => new { x.PointId })
.ToListAsync();
deviceCounts = devices
.Where(x => x.PointId.HasValue)
.GroupBy(x => x.PointId!.Value)
.ToDictionary(g => g.Key, g => g.Count());
// 构建树形结构
var tree = new List<object>();
foreach (var region in regions)
{
var regionChildren = points
.Where(p => p.RegionId == region.Id)
.Select(p => new
{
id = $"p_{p.PointID}",
label = p.PointName ?? $"点位{p.PointID}",
type = "point",
deviceCount = deviceCounts.TryGetValue(p.PointID, out var c) ? c : 0
})
.ToList<object>();
tree.Add(new
{
id = $"r_{region.Id}",
label = region.RegionName ?? $"区域{region.Id}",
type = "region",
deviceCount = regionChildren.Count,
children = regionChildren
});
}
return Ok(tree);
}
/// <summary>
/// 获取指定点位下的设备列表(含子设备)。
/// 支持分页参数 page 和 size。
/// </summary>
[HttpGet]
[Route("/api/DeviceManager/GetDevicesByPoint")]
public async Task<IActionResult> GetDevicesByPoint(int pointId, int page = 1, int size = 20)
{
var query = _service.FindAsIQueryable(x => x.PointId == pointId);
var total = await query.CountAsync();
var items = await query
.Skip((page - 1) * size)
.Take(size)
.OrderBy(x => x.DeviceId)
.Select(x => new
{
x.DeviceId, x.DeviceName, x.AdapterCode, x.SourceId,
x.DeviceCategory, x.DeviceGroup, x.IsParent,
x.ParentDeviceId, x.IsOnline, x.IpAddress, x.Port,
x.Location, x.ExtraData, x.LastSyncTime,
x.MapModelId, x.MapModelScale, x.MapModelRotation, x.Enable
})
.ToListAsync();
return Ok(new { items, total });
}
}
}

View File

@@ -0,0 +1,246 @@
/*
*网关节点管理 — A1注册/A2心跳/A3设备同步/A4告警同步
*A组接口使用 [AllowAnonymous] + NodeToken 二次认证
*所有改动在 Partial 目录,不破坏框架可升级性
*/
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using VolPro.Core.DBManager;
using VolPro.Core.DbSqlSugar;
using VolPro.Entity.DomainModels;
using Warehouse.IRepositories;
using Warehouse.IServices;
using Warehouse.Services;
namespace Warehouse.Controllers
{
public partial class gateway_nodesController
{
private readonly Igateway_nodesService _service;//访问业务代码
private readonly Ibase_deviceService _deviceService;
private readonly Iiot_alarmService _iot_alarmService;
private readonly IHttpContextAccessor _httpContextAccessor;
[ActivatorUtilitiesConstructor]
public gateway_nodesController(
Igateway_nodesService service,
Ibase_deviceService deviceService,
Iiot_alarmService iot_alarmService,
IHttpContextAccessor httpContextAccessor
)
: base(service)
{
_service = service;
_deviceService = deviceService;
_iot_alarmService = iot_alarmService;
_httpContextAccessor = httpContextAccessor;
}
/// <summary>A1: 网关注册Upsert。认证方式: NodeToken</summary>
[HttpPost]
[Route("/api/gateway/register")]
[AllowAnonymous]
public async Task<IActionResult> RegisterGateway([FromBody] GatewayRegisterRequest req)
{
if (string.IsNullOrEmpty(req.NodeCode) || string.IsNullOrEmpty(req.Token))
return BadRequest(new { message = "NodeCode 和 Token 为必填项" });
try
{
var node = await _service.RegisterNodeAsync(req.NodeCode, req.Token, req.AdapterTypes, req.BaseUrl);
// 返回当前网关的顶层设备列表
var devices = await _deviceService.GetDevicesByGatewayNodeAsync(node.NodeId);
return Ok(new { nodeId = node.NodeId, devices = devices.Select(d => new {
d.DeviceId, d.DeviceName, d.AdapterCode, d.SourceId,
d.DeviceCategory, d.DeviceGroup, d.IsParent, d.IsOnline, d.ExtraData
}) });
}
catch (UnauthorizedAccessException)
{
return StatusCode(401, new { message = "认证失败Token 无效" });
}
}
/// <summary>A2: 心跳。认证方式: NodeToken。每15秒调用一次。</summary>
[HttpPost]
[Route("/api/gateway/heartbeat")]
[AllowAnonymous]
public async Task<IActionResult> GatewayHeartbeat([FromBody] GatewayHeartbeatRequest req)
{
if (string.IsNullOrEmpty(req.NodeCode) || string.IsNullOrEmpty(req.Token))
return BadRequest(new { message = "NodeCode 和 Token 为必填项" });
try
{
await _service.UpdateHeartbeatAsync(req.NodeCode, req.Token);
return Ok(new { status = "ok", serverTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") });
}
catch (UnauthorizedAccessException)
{
return StatusCode(401, new { message = "认证失败" });
}
}
/// <summary>A3: 设备数据同步(字段分治 + parentSourceId 映射)。认证方式: NodeToken</summary>
[HttpPost]
[Route("/api/gateway/sync/devices")]
[AllowAnonymous]
public async Task<IActionResult> SyncDevices([FromBody] SyncDevicesRequest req)
{
if (string.IsNullOrEmpty(req.NodeCode) || string.IsNullOrEmpty(req.Token))
return BadRequest(new { message = "NodeCode 和 Token 为必填项" });
try
{
// 认证
var node = await _service.FindAsIQueryable(x => x.NodeCode == req.NodeCode && x.NodeToken == req.Token)
.FirstOrDefaultAsync();
if (node == null) return StatusCode(401, new { message = "认证失败" });
var items = req.Devices.Select(d => new SyncDeviceItem
{
AdapterCode = d.AdapterCode,
SourceId = d.SourceId,
Name = d.Name,
Category = d.Category,
Group = d.Group,
IsParent = d.IsParent,
ParentSourceId = d.ParentSourceId,
IsOnline = d.IsOnline,
IpAddress = d.IpAddress,
Port = d.Port,
ExtraDataJson = d.ExtraDataJson
}).ToList();
var (added, updated) = await _service.SyncDevicesAsync(node.NodeId, items);
return Ok(new { added, updated, removed = 0 });
}
catch (UnauthorizedAccessException)
{
return StatusCode(401, new { message = "认证失败" });
}
}
/// <summary>A4: 告警同步DeviceSourceId→DeviceId 映射 + SourceAlarmId 去重)。认证方式: NodeToken</summary>
[HttpPost]
[Route("/api/gateway/sync/alarms")]
[AllowAnonymous]
public async Task<IActionResult> SyncAlarms([FromBody] SyncAlarmsRequest req)
{
if (string.IsNullOrEmpty(req.NodeCode) || string.IsNullOrEmpty(req.Token))
return BadRequest(new { message = "NodeCode 和 Token 为必填项" });
try
{
var node = await _service.FindAsIQueryable(x => x.NodeCode == req.NodeCode && x.NodeToken == req.Token)
.FirstOrDefaultAsync();
if (node == null) return StatusCode(401, new { message = "认证失败" });
// 获取告警服务
var alarmSvc = _iot_alarmService;
// 批量查询 DeviceSourceId → DeviceId 映射
var deviceSvc = _deviceService;
int added = 0;
foreach (var a in req.Alarms)
{
int? deviceId = null;
if (deviceSvc != null)
{
var dev = await deviceSvc.FindAsIQueryable(
x => x.AdapterCode == a.AdapterCode && x.SourceId == a.DeviceSourceId)
.Select(x => new { x.DeviceId })
.FirstOrDefaultAsync();
deviceId = dev?.DeviceId;
}
if (alarmSvc != null)
{
var alarmItem = new SyncAlarmItem
{
SourceAlarmId = a.SourceAlarmId,
DeviceSourceId = a.DeviceSourceId,
AdapterCode = a.AdapterCode,
Level = a.Level,
Desc = a.Desc,
Value = a.Value,
StartTime = a.StartTime
};
await alarmSvc.UpsertAlarmAsync(alarmItem, deviceId);
added++;
}
}
return Ok(new { added });
}
catch (UnauthorizedAccessException)
{
return StatusCode(401, new { message = "认证失败" });
}
}
}
// ── A 组请求 DTO ──
public class GatewayRegisterRequest
{
public string NodeCode { get; set; } = "";
public string Token { get; set; } = "";
public string AdapterTypes { get; set; } = "";
public string BaseUrl { get; set; } = "";
}
public class GatewayHeartbeatRequest
{
public string NodeCode { get; set; } = "";
public string Token { get; set; } = "";
}
public class SyncDevicesRequest
{
public string NodeCode { get; set; } = "";
public string Token { get; set; } = "";
public List<SyncDeviceItemDto> Devices { get; set; } = new();
}
public class SyncDeviceItemDto
{
public string AdapterCode { get; set; } = "";
public string SourceId { get; set; } = "";
public string? Name { get; set; }
public string? Category { get; set; }
public string? Group { get; set; }
public bool IsParent { get; set; }
public string? ParentSourceId { get; set; }
public bool IsOnline { get; set; }
public string? IpAddress { get; set; }
public int? Port { get; set; }
public string? ExtraDataJson { get; set; }
}
public class SyncAlarmsRequest
{
public string NodeCode { get; set; } = "";
public string Token { get; set; } = "";
public List<SyncAlarmItemDto> Alarms { get; set; } = new();
}
public class SyncAlarmItemDto
{
public string SourceAlarmId { get; set; } = "";
public string DeviceSourceId { get; set; } = "";
public string AdapterCode { get; set; } = "";
public string Level { get; set; } = "";
public string Desc { get; set; } = "";
public double? Value { get; set; }
public string StartTime { get; set; } = "";
}
}

View File

@@ -0,0 +1,33 @@
/*
*接口编写处...
*如果接口需要做Action的权限验证请在Action上使用属性
*如: [ApiActionPermission("iot_alarm",Enums.ActionPermissionOptions.Search)]
*/
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Http;
using VolPro.Entity.DomainModels;
using Warehouse.IServices;
namespace Warehouse.Controllers
{
public partial class iot_alarmController
{
private readonly Iiot_alarmService _service;//访问业务代码
private readonly IHttpContextAccessor _httpContextAccessor;
[ActivatorUtilitiesConstructor]
public iot_alarmController(
Iiot_alarmService service,
IHttpContextAccessor httpContextAccessor
)
: base(service)
{
_service = service;
_httpContextAccessor = httpContextAccessor;
}
}
}

View File

@@ -0,0 +1,33 @@
/*
*接口编写处...
*如果接口需要做Action的权限验证请在Action上使用属性
*如: [ApiActionPermission("iot_devicedata",Enums.ActionPermissionOptions.Search)]
*/
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Http;
using VolPro.Entity.DomainModels;
using Warehouse.IServices;
namespace Warehouse.Controllers
{
public partial class iot_devicedataController
{
private readonly Iiot_devicedataService _service;//访问业务代码
private readonly IHttpContextAccessor _httpContextAccessor;
[ActivatorUtilitiesConstructor]
public iot_devicedataController(
Iiot_devicedataService service,
IHttpContextAccessor httpContextAccessor
)
: base(service)
{
_service = service;
_httpContextAccessor = httpContextAccessor;
}
}
}

View File

@@ -0,0 +1,33 @@
/*
*接口编写处...
*如果接口需要做Action的权限验证请在Action上使用属性
*如: [ApiActionPermission("video_channel",Enums.ActionPermissionOptions.Search)]
*/
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Http;
using VolPro.Entity.DomainModels;
using Warehouse.IServices;
namespace Warehouse.Controllers
{
public partial class video_channelController
{
private readonly Ivideo_channelService _service;//访问业务代码
private readonly IHttpContextAccessor _httpContextAccessor;
[ActivatorUtilitiesConstructor]
public video_channelController(
Ivideo_channelService service,
IHttpContextAccessor httpContextAccessor
)
: base(service)
{
_service = service;
_httpContextAccessor = httpContextAccessor;
}
}
}

View File

@@ -0,0 +1,33 @@
/*
*接口编写处...
*如果接口需要做Action的权限验证请在Action上使用属性
*如: [ApiActionPermission("video_record",Enums.ActionPermissionOptions.Search)]
*/
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Http;
using VolPro.Entity.DomainModels;
using Warehouse.IServices;
namespace Warehouse.Controllers
{
public partial class video_recordController
{
private readonly Ivideo_recordService _service;//访问业务代码
private readonly IHttpContextAccessor _httpContextAccessor;
[ActivatorUtilitiesConstructor]
public video_recordController(
Ivideo_recordService service,
IHttpContextAccessor httpContextAccessor
)
: base(service)
{
_service = service;
_httpContextAccessor = httpContextAccessor;
}
}
}

View File

@@ -0,0 +1,80 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using VolPro.Core.Filters;
using Warehouse.IServices;
namespace Warehouse.Controllers;
/// <summary>
/// 定时任务 API 端点。
/// Vol.Pro 框架通过 Sys_QuartzOptions 表配置 URL+Cron 定时调用。
/// 每个方法加 [ApiTask] 属性以允许框架匿名调用。
///
/// 不在 Controller 层注入具体业务类——通过 HttpContext.RequestServices 按需解析,
/// 避免 Controller 构造函数的 DI 依赖链过长。
/// </summary>
[Route("api/task")]
public class TaskController : Controller
{
/// <summary>T1: 设备同步 — 遍历在线网关触发全量设备同步每5分钟</summary>
[ApiTask]
[HttpGet, HttpPost, Route("syncDevices")]
public async Task<IActionResult> SyncDevices()
{
var sp = HttpContext.RequestServices;
if (sp.GetService<Igateway_nodesService>() == null)
return StatusCode(500, new { error = "服务未注册: gateway_nodesService" });
// 复用 SyncDevicesJob 的核心流程Job 内部自行创建 GatewayClient
var job = new VolPro.Warehouse.Services.SyncDevicesJob(sp);
await job.Execute(null!);
return Ok(new { time = DateTime.Now, status = "ok" });
}
/// <summary>T2: 心跳监控 — 扫描超时网关标记离线每15秒</summary>
[ApiTask]
[HttpGet, HttpPost, Route("heartbeatMonitor")]
public async Task<IActionResult> HeartbeatMonitor()
{
var sp = HttpContext.RequestServices;
var gwSvc = sp.GetService<Igateway_nodesService>();
if (gwSvc == null)
return StatusCode(500, new { error = "服务未注册: gateway_nodesService" });
var job = new VolPro.Warehouse.Services.HeartbeatMonitorJob(sp);
await job.Execute(null!);
return Ok(new { time = DateTime.Now, status = "ok" });
}
/// <summary>T3: 实时轮询 — 拉取 MC4 IoT 实时值每10秒</summary>
[ApiTask]
[HttpGet, HttpPost, Route("realtimePoll")]
public async Task<IActionResult> RealtimePoll()
{
var sp = HttpContext.RequestServices;
var gwSvc = sp.GetService<Igateway_nodesService>();
if (gwSvc == null)
return StatusCode(500, new { error = "服务未注册: gateway_nodesService" });
var job = new VolPro.Warehouse.Services.RealtimePollJob(sp);
await job.Execute(null!);
return Ok(new { time = DateTime.Now, status = "ok" });
}
/// <summary>T4: 规则引擎 — 评估规则+执行动作每10秒</summary>
[ApiTask]
[HttpGet, HttpPost, Route("ruleEngine")]
public async Task<IActionResult> RuleEngine()
{
var sp = HttpContext.RequestServices;
var ruleRepo = sp.GetService<Warehouse.IRepositories.Iwarehouse_ruleRepository>();
if (ruleRepo == null)
return StatusCode(500, new { error = "服务未注册: Iwarehouse_ruleRepository" });
var engine = new Warehouse.Services.RuleEngineService(ruleRepo);
await engine.EvaluateAllAsync();
return Ok(new { time = DateTime.Now, status = "ok" });
}
}

View File

@@ -0,0 +1,21 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*如果要增加方法请在当前目录下Partial文件夹base_deviceController编写
*/
using Microsoft.AspNetCore.Mvc;
using VolPro.Core.Controllers.Basic;
using VolPro.Entity.AttributeManager;
using Warehouse.IServices;
namespace Warehouse.Controllers
{
[Route("api/base_device")]
[PermissionTable(Name = "base_device")]
public partial class base_deviceController : ApiBaseController<Ibase_deviceService>
{
public base_deviceController(Ibase_deviceService service)
: base(service)
{
}
}
}

View File

@@ -0,0 +1,21 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*如果要增加方法请在当前目录下Partial文件夹gateway_nodesController编写
*/
using Microsoft.AspNetCore.Mvc;
using VolPro.Core.Controllers.Basic;
using VolPro.Entity.AttributeManager;
using Warehouse.IServices;
namespace Warehouse.Controllers
{
[Route("api/gateway_nodes")]
[PermissionTable(Name = "gateway_nodes")]
public partial class gateway_nodesController : ApiBaseController<Igateway_nodesService>
{
public gateway_nodesController(Igateway_nodesService service)
: base(service)
{
}
}
}

View File

@@ -0,0 +1,21 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*如果要增加方法请在当前目录下Partial文件夹iot_alarmController编写
*/
using Microsoft.AspNetCore.Mvc;
using VolPro.Core.Controllers.Basic;
using VolPro.Entity.AttributeManager;
using Warehouse.IServices;
namespace Warehouse.Controllers
{
[Route("api/iot_alarm")]
[PermissionTable(Name = "iot_alarm")]
public partial class iot_alarmController : ApiBaseController<Iiot_alarmService>
{
public iot_alarmController(Iiot_alarmService service)
: base(service)
{
}
}
}

View File

@@ -0,0 +1,21 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*如果要增加方法请在当前目录下Partial文件夹iot_devicedataController编写
*/
using Microsoft.AspNetCore.Mvc;
using VolPro.Core.Controllers.Basic;
using VolPro.Entity.AttributeManager;
using Warehouse.IServices;
namespace Warehouse.Controllers
{
[Route("api/iot_devicedata")]
[PermissionTable(Name = "iot_devicedata")]
public partial class iot_devicedataController : ApiBaseController<Iiot_devicedataService>
{
public iot_devicedataController(Iiot_devicedataService service)
: base(service)
{
}
}
}

View File

@@ -0,0 +1,21 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*如果要增加方法请在当前目录下Partial文件夹video_channelController编写
*/
using Microsoft.AspNetCore.Mvc;
using VolPro.Core.Controllers.Basic;
using VolPro.Entity.AttributeManager;
using Warehouse.IServices;
namespace Warehouse.Controllers
{
[Route("api/video_channel")]
[PermissionTable(Name = "video_channel")]
public partial class video_channelController : ApiBaseController<Ivideo_channelService>
{
public video_channelController(Ivideo_channelService service)
: base(service)
{
}
}
}

View File

@@ -0,0 +1,21 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*如果要增加方法请在当前目录下Partial文件夹video_recordController编写
*/
using Microsoft.AspNetCore.Mvc;
using VolPro.Core.Controllers.Basic;
using VolPro.Entity.AttributeManager;
using Warehouse.IServices;
namespace Warehouse.Controllers
{
[Route("api/video_record")]
[PermissionTable(Name = "video_record")]
public partial class video_recordController : ApiBaseController<Ivideo_recordService>
{
public video_recordController(Ivideo_recordService service)
: base(service)
{
}
}
}

View File

@@ -0,0 +1,7 @@
中文提示 : 检测到你没有开启文件AllowLoadLocalInfile=true加到自符串上已自动执行 SET GLOBAL local_infile=1 在试一次
English Message : Loading local data is disabled; this must be enabled on both the client and server sides at SqlSugar.Check.ExceptionEasy(String enMessage, String cnMessage)
at SqlSugar.MySqlFastBuilder.ExecuteBulkCopyAsync(DataTable dt)
at SqlSugar.FastestProvider`1._BulkCopy(List`1 datas)
at SqlSugar.FastestProvider`1.BulkCopyAsync(List`1 datas)
at SqlSugar.FastestProvider`1.BulkCopy(List`1 datas)
at VolPro.Core.Services.Logger.Start() in D:\Code\SecMPS\api_sqlsugar\VolPro.Core\Services\Logger.cs:line 194SqlSugar

View File

@@ -4,13 +4,18 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project>
<PropertyGroup>
<DeleteExistingFiles>False</DeleteExistingFiles>
<ExcludeApp_Data>False</ExcludeApp_Data>
<LaunchSiteAfterPublish>True</LaunchSiteAfterPublish>
<DeleteExistingFiles>true</DeleteExistingFiles>
<ExcludeApp_Data>false</ExcludeApp_Data>
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<PublishProvider>FileSystem</PublishProvider>
<PublishUrl>bin\Release\netcoreapp3.1\net6.0\publish\</PublishUrl>
<WebPublishMethod>FileSystem</WebPublishMethod>
<SiteUrlToLaunchAfterPublish />
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>linux-arm64</RuntimeIdentifier>
<ProjectGuid>4db3c91b-93fe-4937-8b58-ddd3f57d4607</ProjectGuid>
<SelfContained>true</SelfContained>
</PropertyGroup>
</Project>

View File

@@ -69,6 +69,14 @@ namespace VolPro.WebApi
services.AddSession();
services.AddMemoryCache();
services.AddHttpContextAccessor();
// ── 网关集成: HttpClient + GatewayClient 注册 ──
services.AddHttpClient("VolPro", c =>
{
c.Timeout = TimeSpan.FromSeconds(30);
c.DefaultRequestHeaders.Add("Accept", "application/json");
});
services.AddSingleton<VolPro.Warehouse.Services.GatewayClient>();
services.AddMvc(options =>
{
options.Filters.Add(typeof(ApiAuthorizeFilter));

View File

@@ -59,5 +59,11 @@
<None Include="Startup copy.cs" />
</ItemGroup>
<ItemGroup>
<None Update="fonts\DejaVuSans.ttf">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

Binary file not shown.

View File

@@ -0,0 +1,18 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*Repository提供数据库操作如果要增加数据库操作请在当前目录下Partial文件夹Ibase_deviceRepository编写接口
*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using VolPro.Core.BaseProvider;
using VolPro.Entity.DomainModels;
using VolPro.Core.Extensions.AutofacManager;
namespace Warehouse.IRepositories
{
public partial interface Ibase_deviceRepository : IDependency,IRepository<base_device>
{
}
}

View File

@@ -0,0 +1,18 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*Repository提供数据库操作如果要增加数据库操作请在当前目录下Partial文件夹Igateway_nodesRepository编写接口
*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using VolPro.Core.BaseProvider;
using VolPro.Entity.DomainModels;
using VolPro.Core.Extensions.AutofacManager;
namespace Warehouse.IRepositories
{
public partial interface Igateway_nodesRepository : IDependency,IRepository<gateway_nodes>
{
}
}

View File

@@ -0,0 +1,18 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*Repository提供数据库操作如果要增加数据库操作请在当前目录下Partial文件夹Iiot_alarmRepository编写接口
*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using VolPro.Core.BaseProvider;
using VolPro.Entity.DomainModels;
using VolPro.Core.Extensions.AutofacManager;
namespace Warehouse.IRepositories
{
public partial interface Iiot_alarmRepository : IDependency,IRepository<iot_alarm>
{
}
}

View File

@@ -0,0 +1,18 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*Repository提供数据库操作如果要增加数据库操作请在当前目录下Partial文件夹Iiot_devicedataRepository编写接口
*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using VolPro.Core.BaseProvider;
using VolPro.Entity.DomainModels;
using VolPro.Core.Extensions.AutofacManager;
namespace Warehouse.IRepositories
{
public partial interface Iiot_devicedataRepository : IDependency,IRepository<iot_devicedata>
{
}
}

View File

@@ -0,0 +1,18 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*Repository提供数据库操作如果要增加数据库操作请在当前目录下Partial文件夹Ivideo_channelRepository编写接口
*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using VolPro.Core.BaseProvider;
using VolPro.Entity.DomainModels;
using VolPro.Core.Extensions.AutofacManager;
namespace Warehouse.IRepositories
{
public partial interface Ivideo_channelRepository : IDependency,IRepository<video_channel>
{
}
}

View File

@@ -0,0 +1,18 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*Repository提供数据库操作如果要增加数据库操作请在当前目录下Partial文件夹Ivideo_recordRepository编写接口
*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using VolPro.Core.BaseProvider;
using VolPro.Entity.DomainModels;
using VolPro.Core.Extensions.AutofacManager;
namespace Warehouse.IRepositories
{
public partial interface Ivideo_recordRepository : IDependency,IRepository<video_record>
{
}
}

View File

@@ -0,0 +1,12 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*/
using VolPro.Core.BaseProvider;
using VolPro.Entity.DomainModels;
namespace Warehouse.IServices
{
public partial interface Ibase_deviceService : IService<base_device>
{
}
}

View File

@@ -0,0 +1,12 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*/
using VolPro.Core.BaseProvider;
using VolPro.Entity.DomainModels;
namespace Warehouse.IServices
{
public partial interface Igateway_nodesService : IService<gateway_nodes>
{
}
}

View File

@@ -0,0 +1,12 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*/
using VolPro.Core.BaseProvider;
using VolPro.Entity.DomainModels;
namespace Warehouse.IServices
{
public partial interface Iiot_alarmService : IService<iot_alarm>
{
}
}

View File

@@ -0,0 +1,12 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*/
using VolPro.Core.BaseProvider;
using VolPro.Entity.DomainModels;
namespace Warehouse.IServices
{
public partial interface Iiot_devicedataService : IService<iot_devicedata>
{
}
}

View File

@@ -0,0 +1,12 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*/
using VolPro.Core.BaseProvider;
using VolPro.Entity.DomainModels;
namespace Warehouse.IServices
{
public partial interface Ivideo_channelService : IService<video_channel>
{
}
}

View File

@@ -0,0 +1,12 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*/
using VolPro.Core.BaseProvider;
using VolPro.Entity.DomainModels;
namespace Warehouse.IServices
{
public partial interface Ivideo_recordService : IService<video_record>
{
}
}

View File

@@ -0,0 +1,19 @@
/*
*所有关于base_device类的业务代码接口应在此处编写
*/
using System.Linq.Expressions;
using VolPro.Core.BaseProvider;
using VolPro.Core.Utilities;
using VolPro.Entity.DomainModels;
using Warehouse.Services;
namespace Warehouse.IServices
{
public partial interface Ibase_deviceService
{
Task<List<base_device>> GetDevicesByGatewayNodeAsync(int gatewayNodeId);
Task UpsertDeviceAsync(SyncDeviceItem d, int gatewayNodeId, Dictionary<(string, string), int> existingIds);
}
}

View File

@@ -0,0 +1,19 @@
/*
*所有关于gateway_nodes类的业务代码接口应在此处编写
*/
using System.Linq.Expressions;
using VolPro.Core.BaseProvider;
using VolPro.Core.Utilities;
using VolPro.Entity.DomainModels;
using Warehouse.Services;
namespace Warehouse.IServices
{
public partial interface Igateway_nodesService
{
Task<gateway_nodes> RegisterNodeAsync(string nodeCode, string token, string adapterTypes, string baseUrl);
Task UpdateHeartbeatAsync(string nodeCode, string token);
Task<(int added, int updated)> SyncDevicesAsync(int gatewayNodeId, List<SyncDeviceItem> devices);
}
}

View File

@@ -0,0 +1,17 @@
/*
*所有关于iot_alarm类的业务代码接口应在此处编写
*/
using System.Linq.Expressions;
using VolPro.Core.BaseProvider;
using VolPro.Core.Utilities;
using VolPro.Entity.DomainModels;
using Warehouse.Services;
namespace Warehouse.IServices
{
public partial interface Iiot_alarmService
{
Task UpsertAlarmAsync(SyncAlarmItem a, int? deviceId);
}
}

View File

@@ -0,0 +1,13 @@
/*
*所有关于iot_devicedata类的业务代码接口应在此处编写
*/
using VolPro.Core.BaseProvider;
using VolPro.Entity.DomainModels;
using VolPro.Core.Utilities;
using System.Linq.Expressions;
namespace Warehouse.IServices
{
public partial interface Iiot_devicedataService
{
}
}

View File

@@ -0,0 +1,13 @@
/*
*所有关于video_channel类的业务代码接口应在此处编写
*/
using VolPro.Core.BaseProvider;
using VolPro.Entity.DomainModels;
using VolPro.Core.Utilities;
using System.Linq.Expressions;
namespace Warehouse.IServices
{
public partial interface Ivideo_channelService
{
}
}

View File

@@ -0,0 +1,13 @@
/*
*所有关于video_record类的业务代码接口应在此处编写
*/
using VolPro.Core.BaseProvider;
using VolPro.Entity.DomainModels;
using VolPro.Core.Utilities;
using System.Linq.Expressions;
namespace Warehouse.IServices
{
public partial interface Ivideo_recordService
{
}
}

View File

@@ -0,0 +1,24 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*Repository提供数据库操作如果要增加数据库操作请在当前目录下Partial文件夹base_deviceRepository编写代码
*/
using Warehouse.IRepositories;
using VolPro.Core.BaseProvider;
using VolPro.Core.EFDbContext;
using VolPro.Core.Extensions.AutofacManager;
using VolPro.Entity.DomainModels;
namespace Warehouse.Repositories
{
public partial class base_deviceRepository : RepositoryBase<base_device> , Ibase_deviceRepository
{
public base_deviceRepository(ServiceDbContext dbContext)
: base(dbContext)
{
}
public static Ibase_deviceRepository Instance
{
get { return AutofacContainerModule.GetService<Ibase_deviceRepository>(); } }
}
}

View File

@@ -0,0 +1,24 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*Repository提供数据库操作如果要增加数据库操作请在当前目录下Partial文件夹gateway_nodesRepository编写代码
*/
using Warehouse.IRepositories;
using VolPro.Core.BaseProvider;
using VolPro.Core.EFDbContext;
using VolPro.Core.Extensions.AutofacManager;
using VolPro.Entity.DomainModels;
namespace Warehouse.Repositories
{
public partial class gateway_nodesRepository : RepositoryBase<gateway_nodes> , Igateway_nodesRepository
{
public gateway_nodesRepository(ServiceDbContext dbContext)
: base(dbContext)
{
}
public static Igateway_nodesRepository Instance
{
get { return AutofacContainerModule.GetService<Igateway_nodesRepository>(); } }
}
}

View File

@@ -0,0 +1,24 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*Repository提供数据库操作如果要增加数据库操作请在当前目录下Partial文件夹iot_alarmRepository编写代码
*/
using Warehouse.IRepositories;
using VolPro.Core.BaseProvider;
using VolPro.Core.EFDbContext;
using VolPro.Core.Extensions.AutofacManager;
using VolPro.Entity.DomainModels;
namespace Warehouse.Repositories
{
public partial class iot_alarmRepository : RepositoryBase<iot_alarm> , Iiot_alarmRepository
{
public iot_alarmRepository(ServiceDbContext dbContext)
: base(dbContext)
{
}
public static Iiot_alarmRepository Instance
{
get { return AutofacContainerModule.GetService<Iiot_alarmRepository>(); } }
}
}

View File

@@ -0,0 +1,24 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*Repository提供数据库操作如果要增加数据库操作请在当前目录下Partial文件夹iot_devicedataRepository编写代码
*/
using Warehouse.IRepositories;
using VolPro.Core.BaseProvider;
using VolPro.Core.EFDbContext;
using VolPro.Core.Extensions.AutofacManager;
using VolPro.Entity.DomainModels;
namespace Warehouse.Repositories
{
public partial class iot_devicedataRepository : RepositoryBase<iot_devicedata> , Iiot_devicedataRepository
{
public iot_devicedataRepository(ServiceDbContext dbContext)
: base(dbContext)
{
}
public static Iiot_devicedataRepository Instance
{
get { return AutofacContainerModule.GetService<Iiot_devicedataRepository>(); } }
}
}

View File

@@ -0,0 +1,24 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*Repository提供数据库操作如果要增加数据库操作请在当前目录下Partial文件夹video_channelRepository编写代码
*/
using Warehouse.IRepositories;
using VolPro.Core.BaseProvider;
using VolPro.Core.EFDbContext;
using VolPro.Core.Extensions.AutofacManager;
using VolPro.Entity.DomainModels;
namespace Warehouse.Repositories
{
public partial class video_channelRepository : RepositoryBase<video_channel> , Ivideo_channelRepository
{
public video_channelRepository(ServiceDbContext dbContext)
: base(dbContext)
{
}
public static Ivideo_channelRepository Instance
{
get { return AutofacContainerModule.GetService<Ivideo_channelRepository>(); } }
}
}

View File

@@ -0,0 +1,24 @@
/*
*代码由框架生成,任何更改都可能导致被代码生成器覆盖
*Repository提供数据库操作如果要增加数据库操作请在当前目录下Partial文件夹video_recordRepository编写代码
*/
using Warehouse.IRepositories;
using VolPro.Core.BaseProvider;
using VolPro.Core.EFDbContext;
using VolPro.Core.Extensions.AutofacManager;
using VolPro.Entity.DomainModels;
namespace Warehouse.Repositories
{
public partial class video_recordRepository : RepositoryBase<video_record> , Ivideo_recordRepository
{
public video_recordRepository(ServiceDbContext dbContext)
: base(dbContext)
{
}
public static Ivideo_recordRepository Instance
{
get { return AutofacContainerModule.GetService<Ivideo_recordRepository>(); } }
}
}

View File

@@ -0,0 +1,73 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
namespace VolPro.Warehouse.Services;
/// <summary>
/// 网关 HTTP 客户端。封装 Vol.Pro 调用 IntegrationGateway B 组接口的逻辑。
/// 所有对网关的请求统一经此类发出,便于连接池管理和错误处理。
/// </summary>
public class GatewayClient
{
private readonly IHttpClientFactory _httpFactory;
private readonly IConfiguration _config;
public GatewayClient(IHttpClientFactory httpFactory, IConfiguration config)
{
_httpFactory = httpFactory;
_config = config;
}
/// <summary>创建带超时和默认头的 HttpClient</summary>
private HttpClient CreateClient()
{
var client = _httpFactory.CreateClient("VolPro");
client.Timeout = TimeSpan.FromSeconds(30);
return client;
}
/// <summary>
/// B3: 手动触发网关全量设备同步。
/// POST {baseUrl}/api/gateway/devices/sync?adapter={adapterTypes}
/// </summary>
public async Task<JsonDocument?> TriggerFullSyncAsync(string baseUrl, string adapterTypes)
{
var http = CreateClient();
var resp = await http.PostAsync(
$"{baseUrl.TrimEnd('/')}/api/gateway/devices/sync?adapter={Uri.EscapeDataString(adapterTypes)}",
null);
if (!resp.IsSuccessStatusCode) return null;
return await resp.Content.ReadFromJsonAsync<JsonDocument>();
}
/// <summary>
/// B4: 获取设备实时点位值。
/// GET {baseUrl}/api/gateway/realtime/{adapter}/{deviceId}
/// </summary>
public async Task<JsonDocument?> GetRealtimeAsync(string baseUrl, string adapter, string deviceId)
{
var http = CreateClient();
var resp = await http.GetAsync(
$"{baseUrl.TrimEnd('/')}/api/gateway/realtime/{Uri.EscapeDataString(adapter)}/{Uri.EscapeDataString(deviceId)}");
if (!resp.IsSuccessStatusCode) return null;
return await resp.Content.ReadFromJsonAsync<JsonDocument>();
}
/// <summary>
/// B5: 设备反向控制。
/// POST {baseUrl}/api/gateway/realtime/{adapter}/control
/// </summary>
public async Task<bool> ControlDeviceAsync(string baseUrl, string adapter, string deviceId, int pointIndex, double value)
{
var http = CreateClient();
var resp = await http.PostAsJsonAsync(
$"{baseUrl.TrimEnd('/')}/api/gateway/realtime/{adapter}/control",
new { deviceId, pointIndex, value });
return resp.IsSuccessStatusCode;
}
}

View File

@@ -0,0 +1,77 @@
using Quartz;
using Microsoft.Extensions.DependencyInjection;
using Warehouse.IServices;
using VolPro.Entity.DomainModels;
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Warehouse.IRepositories;
namespace VolPro.Warehouse.Services;
/// <summary>
/// 心跳超时检测任务。扫描心跳超时 30 秒的网关节点,标记为离线,
/// 并级联标记该节点下所有设备为离线。
/// Cron 建议: 每 15 秒 ("0/15 * * * * ?")
///
/// 设备与网关的关联通过 AdapterCode 前缀匹配(如设备 AdapterCode="MC4:31ku" 匹配网关 AdapterTypes="MC4:31ku")。
/// </summary>
public class HeartbeatMonitorJob : IJob
{
private readonly IServiceProvider _sp;
public HeartbeatMonitorJob(IServiceProvider sp) { _sp = sp; }
public async Task Execute(IJobExecutionContext? context)
{
var sp = _sp;
if (sp == null) return;
var gwSvc = sp.GetService<Igateway_nodesService>();
var gwRepo = sp.GetService<Igateway_nodesRepository>();
var devRepo = sp.GetService<Ibase_deviceRepository>();
if (gwSvc == null || gwRepo == null || devRepo == null) return;
var timeout = DateTime.Now.AddSeconds(-30);
// 扫描心跳超时的网关(当前在线但心跳超时)
var offlineNodes = await gwSvc.FindAsIQueryable(
x => x.IsOnline == "在线" && x.LastHeartbeat < timeout).ToListAsync();
foreach (var node in offlineNodes)
{
// 标记网关离线
node.IsOnline = "离线";
try { gwRepo.Update(node); } catch { }
Console.WriteLine($"[HeartbeatMonitorJob] 网关 {node.NodeCode} 心跳超时,标记离线");
// 级联标记该网关下所有设备离线(批量 SQL
try
{
var adapterPrefixes = (node.AdapterTypes ?? "")
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(t => t.Trim()).ToList();
if (adapterPrefixes.Any())
{
var allDevices = await devRepo.FindAsIQueryable(
d => d.IsOnline == "在线").ToListAsync();
var matched = allDevices
.Where(d => adapterPrefixes.Any(p => (d.AdapterCode ?? "").StartsWith(p)))
.ToList();
if (matched.Any())
{
foreach (var dev in matched) dev.IsOnline = "离线";
devRepo.UpdateRange(matched);
Console.WriteLine($"[HeartbeatMonitorJob] 级联 {matched.Count} 台设备离线");
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[HeartbeatMonitorJob] 级联离线失败: {ex.Message}");
}
}
}
}

View File

@@ -0,0 +1,94 @@
using Quartz;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Linq;
using System.Threading.Tasks;
using VolPro.Entity.DomainModels;
using Warehouse.IRepositories;
using Microsoft.Extensions.Configuration;
using System.Net.Http;
using Warehouse.IServices;
namespace VolPro.Warehouse.Services;
/// <summary>
/// 实时数据轮询任务。
/// 定时轮询在线 MC4 IoT 设备的实时值 → 写入 iot_devicedata 表。
/// Cron 建议: 每 10 秒 ("0/10 * * * * ?")
///
/// 设备与网关的关联通过 AdapterCode 前缀匹配(如设备 AdapterCode="MC4:31ku" 匹配网关 AdapterTypes="MC4:31ku")。
/// </summary>
public class RealtimePollJob : IJob
{
private readonly IServiceProvider _sp;
public RealtimePollJob(IServiceProvider sp) { _sp = sp; }
public async Task Execute(IJobExecutionContext? context)
{
var sp = _sp;
if (sp == null) return;
var gwSvc = sp.GetService<Igateway_nodesService>();
var devRepo = sp.GetService<Ibase_deviceRepository>();
var dataRepo = sp.GetService<Iiot_devicedataRepository>();
var httpFactory = sp.GetService<IHttpClientFactory>();
var config = sp.GetService<IConfiguration>();
var gatewayClient = httpFactory != null ? new GatewayClient(httpFactory, config!) : null;
if (gwSvc == null || devRepo == null || dataRepo == null || gatewayClient == null) return;
// 1. 查在线 MC4 网关
var onlineNodes = await gwSvc.FindAsIQueryable(x =>
x.IsOnline == "在线" && x.AdapterTypes != null && x.AdapterTypes.Contains("MC4")).ToArrayAsync();
foreach (var node in onlineNodes)
{
try
{
var baseUrl = node.BaseUrl;
if (string.IsNullOrEmpty(baseUrl)) continue;
// 2. 解析网关管理的适配器前缀列表
var adapterPrefixes = (node.AdapterTypes ?? "")
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(t => t.Trim());
// 3. 查该网关下在线的 IoT 设备AdapterCode 前缀匹配)
var devices = await devRepo.FindAsIQueryable(d =>
d.DeviceGroup == "IoT设备" && d.IsOnline == "在线").ToListAsync();
var matchedDevices = devices.Where(d =>
adapterPrefixes.Any(p => (d.AdapterCode ?? "").StartsWith(p))).ToList();
if (!matchedDevices.Any()) continue;
// 4. 逐设备调网关 B4 获取实时值
foreach (var dev in matchedDevices)
{
try
{
var result = await gatewayClient.GetRealtimeAsync(baseUrl, dev.AdapterCode, dev.SourceId);
if (result == null) continue;
var root = result.RootElement;
var points = root.TryGetProperty("pointValues", out var pv) ? pv
: root.TryGetProperty("rows", out var r) ? r
: root;
// 结果可能是 PointValue[] 数组,取第一个点位写入
if (points.ValueKind == System.Text.Json.JsonValueKind.Array && points.GetArrayLength() > 0)
{
var first = points[0];
var entry = new iot_devicedata
{
DeviceId = dev.DeviceId,
PointValue = first.TryGetProperty("value", out var v) ? v.GetDecimal() : (decimal?)null,
UpdateTime = DateTime.Now,
Interval = first.TryGetProperty("interval", out var iv) ? iv.GetInt32() : 10
};
dataRepo.Add(entry);
}
}
catch { /* 单设备失败不阻塞其他设备 */ }
}
}
catch { /* 单网关失败不阻塞其他网关 */ }
}
}
}

View File

@@ -0,0 +1,29 @@
// ═══════════════════════════════════════════
// RuleEngineService — 待实体字段就绪后启用。
// 阻塞原因: warehouse_rule.Enable/LastTriggered/CooldownSec
// warehouse_rulecondition.LastTriggered/RecoveryThreshold_Numeric
// warehouse_ruleaction.ActionType 等字段在实体类中不存在
// 修复顺序: SQL ALTER TABLE → VolPro 代码生成器 → 移除本桩恢复完整实现
// 完整实现见 git history: 提交 "RuleEngine-R2-R4: RuleEngineService+RuleEngineJob"
// ═══════════════════════════════════════════
using System;
using System.Threading.Tasks;
using Warehouse.IRepositories;
namespace Warehouse.Services;
public class RuleEngineService
{
private readonly Iwarehouse_ruleRepository _ruleRepo;
public RuleEngineService(Iwarehouse_ruleRepository ruleRepo)
{
_ruleRepo = ruleRepo;
}
public Task EvaluateAllAsync()
{
throw new NotImplementedException(
"RuleEngineService 待实体字段就绪。步骤: SQL ALTER TABLE → 代码生成器 → git revert 本桩。");
}
}

View File

@@ -0,0 +1,50 @@
using Quartz;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using System.Net.Http;
using Warehouse.IServices;
using VolPro.Entity.DomainModels;
using System;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
namespace VolPro.Warehouse.Services;
/// <summary>
/// 定时设备同步任务。遍历所有在线且启用的网关节点,触发全量设备同步。
/// Cron 建议: 每 5 分钟 ("0 */5 * * * ?")
/// </summary>
public class SyncDevicesJob : IJob
{
private readonly IServiceProvider _sp;
public SyncDevicesJob(IServiceProvider sp) { _sp = sp; }
public async Task Execute(IJobExecutionContext? context)
{
var sp = _sp;
var gwSvc = sp.GetService<Igateway_nodesService>();
var httpFactory = sp.GetService<IHttpClientFactory>();
var config = sp.GetService<IConfiguration>();
var client = httpFactory != null ? new GatewayClient(httpFactory, config!) : null;
if (gwSvc == null || client == null) return;
// 遍历所有在线且启用的网关
var onlineNodes = await gwSvc.FindAsIQueryable(
x => x.IsOnline == "在线" && x.Enable == "启用" && x.BaseUrl != null)
.ToListAsync();
foreach (var node in onlineNodes)
{
try
{
// 触发网关全量同步
await client.TriggerFullSyncAsync(node.BaseUrl!, node.AdapterTypes ?? "");
Console.WriteLine($"[SyncDevicesJob] 网关 {node.NodeCode} 同步触发成功");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[SyncDevicesJob] 网关 {node.NodeCode} 同步失败: {ex.Message}");
}
}
}
}

View File

@@ -0,0 +1,117 @@
/*
*所有关于base_device类的业务代码应在此处编写
*可使用repository.调用常用方法获取EF/Dapper等信息
*如果需要事务请使用repository.DbContextBeginTransaction
*也可使用DBServerProvider.手动获取数据库相关信息
*用户信息、权限、角色等使用UserContext.Current操作
*base_deviceService对增、删、改查、导入、导出、审核业务代码扩展参照ServiceFunFilter
*/
using VolPro.Core.BaseProvider;
using VolPro.Core.Extensions.AutofacManager;
using VolPro.Entity.DomainModels;
using System.Linq;
using VolPro.Core.Utilities;
using System.Linq.Expressions;
using VolPro.Core.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Http;
using Warehouse.IRepositories;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Warehouse.Services
{
public partial class base_deviceService
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly Ibase_deviceRepository _repository;//访问数据库
[ActivatorUtilitiesConstructor]
public base_deviceService(
Ibase_deviceRepository dbRepository,
IHttpContextAccessor httpContextAccessor
)
: base(dbRepository)
{
_httpContextAccessor = httpContextAccessor;
_repository = dbRepository;
//多租户会用到这init代码其他情况可以不用
//base.Init(dbRepository);
}
/// <summary>
/// 获取指定网关节点下的顶层设备列表(用于网关注册时返回所管设备)。
/// 返回 ParentDeviceId 为 null 的设备。
/// </summary>
public async Task<List<base_device>> GetDevicesByGatewayNodeAsync(int gatewayNodeId)
{
return await _repository.DbContext.Queryable<base_device>()
.Where(x => x.NodeId == gatewayNodeId && x.ParentDeviceId == null)
.ToListAsync();
}
/// <summary>
/// 按字段分治原则 Upsert 单个设备。
/// 首次入库时写全量管理员字段已有记录时仅更新网关字段IsOnline/ExtraData等
/// </summary>
/// <param name="d">同步设备条目</param>
/// <param name="gatewayNodeId">网关节点ID</param>
/// <param name="existingIds">已有设备映射表 (AdapterCode, SourceId) → DeviceId</param>
[Obsolete("已迁移至 gateway_nodesService.SyncDevicesAsync")]
public async Task UpsertDeviceAsync(SyncDeviceItem d, int gatewayNodeId, Dictionary<(string, string), int> existingIds)
{
var db = _repository.DbContext;
var key = (d.AdapterCode, d.SourceId);
existingIds.TryGetValue(key, out var existingId);
bool isNew = existingId == 0;
// 解析父设备
int? parentDeviceId = null;
if (!string.IsNullOrEmpty(d.ParentSourceId))
{
existingIds.TryGetValue((d.AdapterCode, d.ParentSourceId), out var pid);
if (pid > 0) parentDeviceId = pid;
}
if (isNew)
{
var entity = new base_device
{
DeviceName = d.Name ?? $"DEV_{d.SourceId}",
AdapterCode = d.AdapterCode,
SourceId = d.SourceId,
DeviceCategory = d.Category,
DeviceGroup = d.Group,
NodeId = gatewayNodeId,
IsParent = d.IsParent ? "是" : "否",
ParentDeviceId = parentDeviceId,
IsOnline = d.IsOnline ? "在线" : "离线",
IpAddress = d.IpAddress,
Port = d.Port,
ExtraData = d.ExtraDataJson,
Enable = "启用",
LastSyncTime = DateTime.Now,
CreateDate = DateTime.Now
};
db.Insertable(entity).ExecuteCommand();
}
else
{
var entity = db.Queryable<base_device>().InSingle(existingId);
if (entity != null)
{
entity.IsOnline = d.IsOnline ? "在线" : "离线";
entity.IsParent = d.IsParent ? "是" : "否";
entity.ParentDeviceId = parentDeviceId ?? entity.ParentDeviceId;
entity.IpAddress = d.IpAddress;
entity.Port = d.Port;
entity.ExtraData = d.ExtraDataJson ?? entity.ExtraData;
entity.LastSyncTime = DateTime.Now;
db.Updateable(entity).ExecuteCommand();
}
}
}
}
}

View File

@@ -0,0 +1,194 @@
/*
*所有关于gateway_nodes类的业务代码应在此处编写
*可使用repository.调用常用方法获取EF/Dapper等信息
*如果需要事务请使用repository.DbContextBeginTransaction
*也可使用DBServerProvider.手动获取数据库相关信息
*用户信息、权限、角色等使用UserContext.Current操作
*gateway_nodesService对增、删、改查、导入、导出、审核业务代码扩展参照ServiceFunFilter
*/
using VolPro.Core.BaseProvider;
using VolPro.Core.Extensions.AutofacManager;
using VolPro.Entity.DomainModels;
using System.Linq;
using VolPro.Core.Utilities;
using System.Linq.Expressions;
using VolPro.Core.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Http;
using Warehouse.IRepositories;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Text.Json;
namespace Warehouse.Services
{
/// <summary>
/// gateway_nodes 业务逻辑partial。注册/心跳/设备同步。
/// </summary>
public partial class gateway_nodesService
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly Igateway_nodesRepository _repository;
[ActivatorUtilitiesConstructor]
public gateway_nodesService(
Igateway_nodesRepository dbRepository,
IHttpContextAccessor httpContextAccessor
)
: base(dbRepository)
{
_httpContextAccessor = httpContextAccessor;
_repository = dbRepository;
}
/// <summary>
/// 网关注册Upsert
/// NodeCode 匹配则更新适配器类型/地址/在线状态;
/// NodeCode 不匹配且 Token 验证通过则插入新记录。
/// </summary>
[Obsolete("由 A1 API Controller 自动调用,不建议手动调用")]
public async Task<gateway_nodes> RegisterNodeAsync(string nodeCode, string token, string adapterTypes, string baseUrl)
{
var existingList = await _repository.FindAsIQueryable(x => x.NodeCode == nodeCode).ToListAsync();
var existing = existingList.FirstOrDefault();
gateway_nodes entity;
if (existing != null)
{
if (existing.NodeToken != token)
throw new UnauthorizedAccessException("NodeToken 不匹配");
existing.AdapterTypes = adapterTypes;
existing.BaseUrl = baseUrl;
existing.IsOnline = "在线";
existing.LastHeartbeat = DateTime.Now;
_repository.DbContext.Updateable(existing).ExecuteCommand();
entity = existing;
}
else
{
entity = new gateway_nodes
{
NodeCode = nodeCode,
NodeName = nodeCode,
NodeToken = token,
AdapterTypes = adapterTypes,
BaseUrl = baseUrl,
IsOnline = "在线",
Enable = "启用",
LastHeartbeat = DateTime.Now,
CreateDate = DateTime.Now
};
_repository.DbContext.Insertable(entity).ExecuteCommand();
}
return entity;
}
/// <summary>
/// 心跳更新。更新 LastHeartbeat 并标记在线。
/// </summary>
[Obsolete("由 A2 API Controller 自动调用,不建议手动调用")]
public async Task UpdateHeartbeatAsync(string nodeCode, string token)
{
var entityList = await _repository.FindAsIQueryable(x => x.NodeCode == nodeCode && x.NodeToken == token).ToListAsync();
var entity = entityList.FirstOrDefault();
if (entity == null)
throw new UnauthorizedAccessException("认证失败NodeCode 或 Token 无效");
entity.IsOnline = "在线";
entity.LastHeartbeat = DateTime.Now;
_repository.DbContext.Updateable(entity).ExecuteCommand();
}
/// <summary>
/// 设备数据同步。按字段分治原则写入 base_device
/// 首次入库写全量,后续仅更新网关字段。
/// parentSourceId 解析为 ParentDeviceId。
/// </summary>
[Obsolete("由 A3 API Controller 自动调用,不建议手动调用")]
public async Task<(int added, int updated)> SyncDevicesAsync(int gatewayNodeId, List<SyncDeviceItem> devices)
{
var db = _repository.DbContext;
var adapterCodes = devices.Select(d => d.AdapterCode).Distinct().ToList();
var existingIds = db.Queryable<base_device>()
.Where(x => x.NodeId == gatewayNodeId && adapterCodes.Contains(x.AdapterCode))
.ToList()
.ToDictionary(x => (x.AdapterCode, x.SourceId), x => x.DeviceId);
int added = 0, updated = 0;
foreach (var d in devices)
{
var key = (d.AdapterCode, d.SourceId);
existingIds.TryGetValue(key, out var existingId);
bool isNew = existingId == 0;
int? parentDeviceId = null;
if (!string.IsNullOrEmpty(d.ParentSourceId))
{
existingIds.TryGetValue((d.AdapterCode, d.ParentSourceId), out var pid);
if (pid > 0) parentDeviceId = pid;
}
if (isNew)
{
var entity = new base_device
{
DeviceName = d.Name ?? $"DEV_{d.SourceId}",
AdapterCode = d.AdapterCode,
SourceId = d.SourceId,
DeviceCategory = d.Category,
DeviceGroup = d.Group,
NodeId = gatewayNodeId,
IsParent = d.IsParent ? "是" : "否",
ParentDeviceId = parentDeviceId,
IsOnline = d.IsOnline ? "在线" : "离线",
IpAddress = d.IpAddress,
Port = d.Port,
ExtraData = d.ExtraDataJson,
Enable = "启用",
LastSyncTime = DateTime.Now,
CreateDate = DateTime.Now
};
db.Insertable(entity).ExecuteCommand();
added++;
}
else
{
var entity = db.Queryable<base_device>().InSingle(existingId);
if (entity != null)
{
entity.IsOnline = d.IsOnline ? "在线" : "离线";
entity.IsParent = d.IsParent ? "是" : "否";
entity.ParentDeviceId = parentDeviceId ?? entity.ParentDeviceId;
entity.IpAddress = d.IpAddress;
entity.Port = d.Port;
entity.ExtraData = d.ExtraDataJson ?? entity.ExtraData;
entity.LastSyncTime = DateTime.Now;
db.Updateable(entity).ExecuteCommand();
updated++;
}
}
}
return (added, updated);
}
}
/// <summary>网关同步设备条目</summary>
public class SyncDeviceItem
{
public string AdapterCode { get; set; } = "";
public string SourceId { get; set; } = "";
public string? Name { get; set; }
public string? Category { get; set; }
public string? Group { get; set; }
public bool IsParent { get; set; }
public string? ParentSourceId { get; set; }
public bool IsOnline { get; set; }
public string? IpAddress { get; set; }
public int? Port { get; set; }
public string? ExtraDataJson { get; set; }
}
}

View File

@@ -0,0 +1,82 @@
/*
*所有关于iot_alarm类的业务代码应在此处编写
*可使用repository.调用常用方法获取EF/Dapper等信息
*如果需要事务请使用repository.DbContextBeginTransaction
*也可使用DBServerProvider.手动获取数据库相关信息
*用户信息、权限、角色等使用UserContext.Current操作
*iot_alarmService对增、删、改查、导入、导出、审核业务代码扩展参照ServiceFunFilter
*/
using VolPro.Core.BaseProvider;
using VolPro.Core.Extensions.AutofacManager;
using VolPro.Entity.DomainModels;
using System.Linq;
using VolPro.Core.Utilities;
using System.Linq.Expressions;
using VolPro.Core.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Http;
using Warehouse.IRepositories;
using System;
using System.Threading.Tasks;
namespace Warehouse.Services
{
public partial class iot_alarmService
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly Iiot_alarmRepository _repository;//访问数据库
[ActivatorUtilitiesConstructor]
public iot_alarmService(
Iiot_alarmRepository dbRepository,
IHttpContextAccessor httpContextAccessor
)
: base(dbRepository)
{
_httpContextAccessor = httpContextAccessor;
_repository = dbRepository;
//多租户会用到这init代码其他情况可以不用
//base.Init(dbRepository);
}
/// <summary>
/// Upsert 单条告警。按 SourceAlarmId 去重,已存在则跳过。
/// </summary>
public async Task UpsertAlarmAsync(SyncAlarmItem a, int? deviceId)
{
var db = _repository.DbContext;
// SourceAlarmId 去重
var exists = db.Queryable<iot_alarm>()
.Any(x => x.SourceAlarmId == a.SourceAlarmId);
if (exists) return;
var alarm = new iot_alarm
{
SourceAlarmId = a.SourceAlarmId,
DeviceId = (int)deviceId,
AdapterCode = a.AdapterCode,
AlarmLevel = a.Level,
AlarmDesc = a.Desc,
AlarmValue = (decimal?)a.Value,
StartTime = DateTime.TryParse(a.StartTime, out var st) ? st : DateTime.Now,
State = "未确认",
CreateDate = DateTime.Now
};
db.Insertable(alarm).ExecuteCommand();
}
}
/// <summary>告警同步条目A4 接口接收的数据模型)</summary>
public class SyncAlarmItem
{
public string SourceAlarmId { get; set; } = "";
public string DeviceSourceId { get; set; } = "";
public string AdapterCode { get; set; } = "";
public string Level { get; set; } = "";
public string Desc { get; set; } = "";
public double? Value { get; set; }
public string StartTime { get; set; } = "";
}
}

View File

@@ -0,0 +1,41 @@
/*
*所有关于iot_devicedata类的业务代码应在此处编写
*可使用repository.调用常用方法获取EF/Dapper等信息
*如果需要事务请使用repository.DbContextBeginTransaction
*也可使用DBServerProvider.手动获取数据库相关信息
*用户信息、权限、角色等使用UserContext.Current操作
*iot_devicedataService对增、删、改查、导入、导出、审核业务代码扩展参照ServiceFunFilter
*/
using VolPro.Core.BaseProvider;
using VolPro.Core.Extensions.AutofacManager;
using VolPro.Entity.DomainModels;
using System.Linq;
using VolPro.Core.Utilities;
using System.Linq.Expressions;
using VolPro.Core.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Http;
using Warehouse.IRepositories;
namespace Warehouse.Services
{
public partial class iot_devicedataService
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly Iiot_devicedataRepository _repository;//访问数据库
[ActivatorUtilitiesConstructor]
public iot_devicedataService(
Iiot_devicedataRepository dbRepository,
IHttpContextAccessor httpContextAccessor
)
: base(dbRepository)
{
_httpContextAccessor = httpContextAccessor;
_repository = dbRepository;
//多租户会用到这init代码其他情况可以不用
//base.Init(dbRepository);
}
}
}

View File

@@ -0,0 +1,41 @@
/*
*所有关于video_channel类的业务代码应在此处编写
*可使用repository.调用常用方法获取EF/Dapper等信息
*如果需要事务请使用repository.DbContextBeginTransaction
*也可使用DBServerProvider.手动获取数据库相关信息
*用户信息、权限、角色等使用UserContext.Current操作
*video_channelService对增、删、改查、导入、导出、审核业务代码扩展参照ServiceFunFilter
*/
using VolPro.Core.BaseProvider;
using VolPro.Core.Extensions.AutofacManager;
using VolPro.Entity.DomainModels;
using System.Linq;
using VolPro.Core.Utilities;
using System.Linq.Expressions;
using VolPro.Core.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Http;
using Warehouse.IRepositories;
namespace Warehouse.Services
{
public partial class video_channelService
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly Ivideo_channelRepository _repository;//访问数据库
[ActivatorUtilitiesConstructor]
public video_channelService(
Ivideo_channelRepository dbRepository,
IHttpContextAccessor httpContextAccessor
)
: base(dbRepository)
{
_httpContextAccessor = httpContextAccessor;
_repository = dbRepository;
//多租户会用到这init代码其他情况可以不用
//base.Init(dbRepository);
}
}
}

View File

@@ -0,0 +1,41 @@
/*
*所有关于video_record类的业务代码应在此处编写
*可使用repository.调用常用方法获取EF/Dapper等信息
*如果需要事务请使用repository.DbContextBeginTransaction
*也可使用DBServerProvider.手动获取数据库相关信息
*用户信息、权限、角色等使用UserContext.Current操作
*video_recordService对增、删、改查、导入、导出、审核业务代码扩展参照ServiceFunFilter
*/
using VolPro.Core.BaseProvider;
using VolPro.Core.Extensions.AutofacManager;
using VolPro.Entity.DomainModels;
using System.Linq;
using VolPro.Core.Utilities;
using System.Linq.Expressions;
using VolPro.Core.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Http;
using Warehouse.IRepositories;
namespace Warehouse.Services
{
public partial class video_recordService
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly Ivideo_recordRepository _repository;//访问数据库
[ActivatorUtilitiesConstructor]
public video_recordService(
Ivideo_recordRepository dbRepository,
IHttpContextAccessor httpContextAccessor
)
: base(dbRepository)
{
_httpContextAccessor = httpContextAccessor;
_repository = dbRepository;
//多租户会用到这init代码其他情况可以不用
//base.Init(dbRepository);
}
}
}

View File

@@ -0,0 +1,22 @@
/*
*Authorjxx
*Contact283591387@qq.com
*代码由框架生成,此处任何更改都可能导致被代码生成器覆盖
*所有业务编写全部应在Partial文件夹下base_deviceService与Ibase_deviceService中编写
*/
using Warehouse.IRepositories;
using Warehouse.IServices;
using VolPro.Core.BaseProvider;
using VolPro.Core.Extensions.AutofacManager;
using VolPro.Entity.DomainModels;
namespace Warehouse.Services
{
public partial class base_deviceService : ServiceBase<base_device, Ibase_deviceRepository>
, Ibase_deviceService, IDependency
{
public static Ibase_deviceService Instance
{
get { return AutofacContainerModule.GetService<Ibase_deviceService>(); } }
}
}

View File

@@ -0,0 +1,22 @@
/*
*Authorjxx
*Contact283591387@qq.com
*代码由框架生成,此处任何更改都可能导致被代码生成器覆盖
*所有业务编写全部应在Partial文件夹下gateway_nodesService与Igateway_nodesService中编写
*/
using Warehouse.IRepositories;
using Warehouse.IServices;
using VolPro.Core.BaseProvider;
using VolPro.Core.Extensions.AutofacManager;
using VolPro.Entity.DomainModels;
namespace Warehouse.Services
{
public partial class gateway_nodesService : ServiceBase<gateway_nodes, Igateway_nodesRepository>
, Igateway_nodesService, IDependency
{
public static Igateway_nodesService Instance
{
get { return AutofacContainerModule.GetService<Igateway_nodesService>(); } }
}
}

View File

@@ -0,0 +1,22 @@
/*
*Authorjxx
*Contact283591387@qq.com
*代码由框架生成,此处任何更改都可能导致被代码生成器覆盖
*所有业务编写全部应在Partial文件夹下iot_alarmService与Iiot_alarmService中编写
*/
using Warehouse.IRepositories;
using Warehouse.IServices;
using VolPro.Core.BaseProvider;
using VolPro.Core.Extensions.AutofacManager;
using VolPro.Entity.DomainModels;
namespace Warehouse.Services
{
public partial class iot_alarmService : ServiceBase<iot_alarm, Iiot_alarmRepository>
, Iiot_alarmService, IDependency
{
public static Iiot_alarmService Instance
{
get { return AutofacContainerModule.GetService<Iiot_alarmService>(); } }
}
}

View File

@@ -0,0 +1,22 @@
/*
*Authorjxx
*Contact283591387@qq.com
*代码由框架生成,此处任何更改都可能导致被代码生成器覆盖
*所有业务编写全部应在Partial文件夹下iot_devicedataService与Iiot_devicedataService中编写
*/
using Warehouse.IRepositories;
using Warehouse.IServices;
using VolPro.Core.BaseProvider;
using VolPro.Core.Extensions.AutofacManager;
using VolPro.Entity.DomainModels;
namespace Warehouse.Services
{
public partial class iot_devicedataService : ServiceBase<iot_devicedata, Iiot_devicedataRepository>
, Iiot_devicedataService, IDependency
{
public static Iiot_devicedataService Instance
{
get { return AutofacContainerModule.GetService<Iiot_devicedataService>(); } }
}
}

View File

@@ -0,0 +1,22 @@
/*
*Authorjxx
*Contact283591387@qq.com
*代码由框架生成,此处任何更改都可能导致被代码生成器覆盖
*所有业务编写全部应在Partial文件夹下video_channelService与Ivideo_channelService中编写
*/
using Warehouse.IRepositories;
using Warehouse.IServices;
using VolPro.Core.BaseProvider;
using VolPro.Core.Extensions.AutofacManager;
using VolPro.Entity.DomainModels;
namespace Warehouse.Services
{
public partial class video_channelService : ServiceBase<video_channel, Ivideo_channelRepository>
, Ivideo_channelService, IDependency
{
public static Ivideo_channelService Instance
{
get { return AutofacContainerModule.GetService<Ivideo_channelService>(); } }
}
}

View File

@@ -0,0 +1,22 @@
/*
*Authorjxx
*Contact283591387@qq.com
*代码由框架生成,此处任何更改都可能导致被代码生成器覆盖
*所有业务编写全部应在Partial文件夹下video_recordService与Ivideo_recordService中编写
*/
using Warehouse.IRepositories;
using Warehouse.IServices;
using VolPro.Core.BaseProvider;
using VolPro.Core.Extensions.AutofacManager;
using VolPro.Entity.DomainModels;
namespace Warehouse.Services
{
public partial class video_recordService : ServiceBase<video_record, Ivideo_recordRepository>
, Ivideo_recordService, IDependency
{
public static Ivideo_recordService Instance
{
get { return AutofacContainerModule.GetService<Ivideo_recordService>(); } }
}
}

View File

@@ -1,168 +1,193 @@
-- ============================================
-- SecMPS v2.0 数据库建表脚本
-- SecMPS v3.0 数据库建表脚本6张表
-- 数据库: gljs_main
-- 扩展表已合并到 Base_Device.ExtraData(JSON)
-- ============================================
USE gljs_main;
-- ============================================
-- 1. 统一设备主表
CREATE TABLE IF NOT EXISTS Base_Device (
DeviceId CHAR(36) NOT NULL PRIMARY KEY,
DeviceName NVARCHAR(100) NOT NULL,
AdapterCode NVARCHAR(50) NOT NULL,
SourceId NVARCHAR(100) NOT NULL,
DeviceCategory INT NOT NULL DEFAULT 1,
DeviceType NVARCHAR(50),
RegionId INT NULL,
IsParent TINYINT NOT NULL DEFAULT 0,
ParentDeviceId CHAR(36) NULL,
IsOnline TINYINT NOT NULL DEFAULT 0,
IpAddress NVARCHAR(50),
Port INT,
Location NVARCHAR(200),
Lat DOUBLE,
Lng DOUBLE,
MapModelId NVARCHAR(100),
MapModelScale FLOAT DEFAULT 1.0,
MapModelRotation NVARCHAR(100),
ExtraData TEXT,
LocalOverrides TEXT,
SyncVersion BIGINT DEFAULT 0,
LastSyncTime DATETIME,
Enable TINYINT DEFAULT 1,
Remark NVARCHAR(500),
CreateID INT,
Creator NVARCHAR(50),
CreateDate DATETIME DEFAULT CURRENT_TIMESTAMP,
ModifyID INT,
Modifier NVARCHAR(50),
ModifyDate DATETIME,
UNIQUE INDEX IX_Base_Device_Adapter_Source (AdapterCode, SourceId),
INDEX IX_Base_Device_RegionId (RegionId),
INDEX IX_Base_Device_ParentId (ParentDeviceId)
-- ExtraData(JSON) 承载所有适配器特有字段
-- DeviceGroup 路由到正确的网关Adapter和前端按钮组
-- ============================================
DROP TABLE IF EXISTS base_device;
CREATE TABLE base_device (
DeviceId INT AUTO_INCREMENT COMMENT '设备ID',
DeviceName NVARCHAR(100) NOT NULL COMMENT '设备名称',
AdapterCode NVARCHAR(50) COMMENT '来源适配器(类型:实例)',
SourceId NVARCHAR(100) COMMENT '源系统设备ID',
DeviceCategory NVARCHAR(50) NOT NULL COMMENT '设备种类(数据字典:门磁/空调/智能断路器/人行道闸/车辆道闸/485钥匙柜/网络钥匙柜/紧急报警按钮/红外报警器/门禁一体机/除湿_恒湿机/空调控制器/烟雾报警器/气体报警器/温湿度变送器/摄像机/硬盘录像机/动环采集器)',
DeviceGroup NVARCHAR(20) NOT NULL COMMENT '设备分组(数据字典:视频设备/IoT设备/门禁设备/道闸设备/报警设备)',
PointId INT NULL COMMENT '所属点位ID',
NodeId INT NULL COMMENT '所属网关节点ID',
IsParent NVARCHAR(20) NOT NULL DEFAULT '' COMMENT '是否父设备(数据字典:是/否)',
ParentDeviceId INT NULL COMMENT '父设备ID(自引用,子设备挂父设备下)',
IsOnline NVARCHAR(20) DEFAULT '离线' COMMENT '在线状态(数据字典:在线/离线)',
IpAddress NVARCHAR(50) COMMENT 'IP地址',
Port INT COMMENT '端口',
Location NVARCHAR(200) COMMENT '安装位置',
Lat DOUBLE COMMENT '纬度',
Lng DOUBLE COMMENT '经度',
MapModelId NVARCHAR(100) COMMENT '三维地图模型ID',
MapModelScale FLOAT DEFAULT 1.0 COMMENT '模型缩放比例',
MapModelRotation NVARCHAR(100) COMMENT '模型旋转角度(JSON)',
ExtraData TEXT COMMENT '适配器扩展数据JSON(Owl/MC4/门禁字段均存于此)',
LastSyncTime DATETIME COMMENT '上次同步时间',
Enable NVARCHAR(20) DEFAULT '启用' COMMENT '启用状态(数据字典:启用/禁用)',
Remark NVARCHAR(500) COMMENT '备注',
CreateID INT COMMENT '创建人ID',
Creator NVARCHAR(50) COMMENT '创建人',
CreateDate DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
ModifyID INT COMMENT '修改人ID',
Modifier NVARCHAR(50) COMMENT '修改人',
ModifyDate DATETIME COMMENT '修改时间',
PRIMARY KEY (DeviceId),
INDEX IX_Sync (AdapterCode, SourceId),
INDEX IX_Point (PointId),
INDEX IX_Parent (ParentDeviceId),
INDEX IX_Gateway (GatewayNodeId),
INDEX IX_Group (DeviceGroup)
) COMMENT '统一设备主表';
-- ============================================
-- 2. 视频通道表
-- DeviceId(INT) → base_device.DeviceId
-- ============================================
DROP TABLE IF EXISTS video_channel;
CREATE TABLE video_channel (
ChannelId INT AUTO_INCREMENT COMMENT '通道记录ID',
OwlChannelId NVARCHAR(64) NOT NULL COMMENT 'Owl系统通道ID',
DeviceId INT NOT NULL COMMENT '关联Base_Device设备ID',
OwlStreamApp NVARCHAR(50) COMMENT 'Owl流应用名',
OwlStreamName NVARCHAR(100) COMMENT 'Owl流名称',
HasPtz TINYINT DEFAULT 0 COMMENT '是否支持云台',
HasRecording TINYINT DEFAULT 0 COMMENT '是否支持录像',
RecordMode INT DEFAULT 0 COMMENT '录像模式',
SnapshotUrl NVARCHAR(500) COMMENT '快照地址',
CreateDate DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (ChannelId),
INDEX IX_Device (DeviceId),
INDEX IX_Owl (OwlChannelId)
) COMMENT '视频通道表';
-- ============================================
-- 3. 录像记录表
-- ChannelId(INT) → video_channel.ChannelId
-- ============================================
DROP TABLE IF EXISTS video_record;
CREATE TABLE video_record (
RecordId INT AUTO_INCREMENT COMMENT '录像记录ID',
ChannelId INT NOT NULL COMMENT '关联通道ID',
OwlRecordId INT NOT NULL COMMENT 'Owl录像记录ID',
App NVARCHAR(50) COMMENT '应用名',
Stream NVARCHAR(100) COMMENT '流ID',
StartedAt DATETIME NOT NULL COMMENT '录像开始时间',
EndedAt DATETIME COMMENT '录像结束时间',
Duration DOUBLE DEFAULT 0 COMMENT '录像时长(秒)',
FilePath NVARCHAR(500) COMMENT '文件路径',
FileSize BIGINT DEFAULT 0 COMMENT '文件大小(字节)',
CreateDate DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (RecordId),
INDEX IX_Channel (ChannelId),
INDEX IX_Time (StartedAt)
) COMMENT '录像记录表';
-- ============================================
-- 4. 设备数据归档表
-- DeviceId(INT) → base_device.DeviceId
-- ============================================
DROP TABLE IF EXISTS iot_devicedata;
CREATE TABLE iot_devicedata (
DataId INT AUTO_INCREMENT COMMENT '数据记录ID',
DeviceId INT NOT NULL COMMENT '关联设备ID(子设备/点位)',
PointValue DOUBLE COMMENT '点位数值',
UpdateTime DATETIME NOT NULL COMMENT '数据更新时间',
`Interval` INT DEFAULT 0 COMMENT '采集间隔(毫秒)',
ArchiveType INT DEFAULT 1 COMMENT '归档类型(1小时/2日)',
CreateDate DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (DataId),
INDEX IX_Device (DeviceId),
INDEX IX_Time (CreateDate)
) COMMENT '设备数据归档表';
-- ============================================
-- 5. 告警记录表(通用)
-- DeviceId(INT) → base_device.DeviceId
-- ============================================
DROP TABLE IF EXISTS iot_alarm;
CREATE TABLE iot_alarm (
AlarmId INT AUTO_INCREMENT COMMENT '告警ID',
SourceAlarmId NVARCHAR(100) NOT NULL COMMENT '源系统告警ID',
DeviceId INT NOT NULL COMMENT '关联设备ID',
AlarmType INT DEFAULT 0 COMMENT '告警类型',
AlarmLevel NVARCHAR(20) DEFAULT '提示' COMMENT '告警等级(数据字典:提示/普通/重要/紧急)',
AlarmDesc NVARCHAR(500) COMMENT '告警描述',
AlarmValue DOUBLE COMMENT '触发值',
StartTime DATETIME NOT NULL COMMENT '告警开始时间',
EndTime DATETIME COMMENT '告警结束时间',
ConfirmTime DATETIME COMMENT '确认时间',
ConfirmUser NVARCHAR(50) COMMENT '确认人',
State NVARCHAR(20) DEFAULT '未确认' COMMENT '状态(数据字典:未确认/已确认/已结束)',
AdapterCode NVARCHAR(50) COMMENT '来源适配器',
CreateDate DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (AlarmId),
INDEX IX_Device (DeviceId),
INDEX IX_Source (SourceAlarmId),
INDEX IX_Time (StartTime),
INDEX IX_Level (AlarmLevel)
) COMMENT '告警记录表';
-- ============================================
-- 6. 网关节点注册表
-- ============================================
DROP TABLE IF EXISTS gateway_nodes;
CREATE TABLE gateway_nodes (
NodeId INT AUTO_INCREMENT COMMENT '网关节点ID',
NodeCode NVARCHAR(50) NOT NULL COMMENT '网关唯一编码',
NodeName NVARCHAR(100) NOT NULL COMMENT '网关名称',
NodeToken NVARCHAR(100) NOT NULL COMMENT '认证令牌',
AdapterTypes NVARCHAR(200) COMMENT '支持的适配器类型(网关上报)',
BaseUrl NVARCHAR(200) COMMENT '网关自身地址(网关上报)',
LastHeartbeat DATETIME COMMENT '上次心跳时间',
IsOnline NVARCHAR(20) DEFAULT '离线' COMMENT '在线状态(数据字典:在线/离线)',
Enable NVARCHAR(20) DEFAULT '启用' COMMENT '启用状态(数据字典:启用/禁用)',
Remark NVARCHAR(500) COMMENT '备注',
CreateID INT COMMENT '创建人ID',
Creator NVARCHAR(50) COMMENT '创建人',
CreateDate DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
ModifyID INT COMMENT '修改人ID',
Modifier NVARCHAR(50) COMMENT '修改人',
ModifyDate DATETIME COMMENT '修改时间',
PRIMARY KEY (NodeId),
UNIQUE INDEX IX_Code (NodeCode),
INDEX IX_Online (IsOnline)
) COMMENT '网关节点注册表';
-- ═══════════════════════════════════════════════
-- SecMPS 规则引擎: warehouse_variable 变量定义表 (P1-6)
-- =================================================
-- 规则条件/动作的 ValueId 绑定到此表的 VariableId
-- DeviceId 关联 base_device.DeviceId
-- =================================================
CREATE TABLE warehouse_variable (
VariableId INT IDENTITY(1,1) PRIMARY KEY,
DeviceId INT NOT NULL,
VariableName NVARCHAR(255) NOT NULL, -- 温度/湿度/人数
PointIndex INT DEFAULT 0, -- MC4 pointIndex / Owl 统计量编码
Unit NVARCHAR(50) NULL, -- ℃/%/人
SortOrder INT DEFAULT 0
);
-- 2. 视频设备扩展表
CREATE TABLE IF NOT EXISTS Device_Video_Ext (
ExtId CHAR(36) NOT NULL PRIMARY KEY,
DeviceId CHAR(36) NOT NULL,
OwlDeviceId NVARCHAR(64) NOT NULL,
Protocol INT DEFAULT 1,
Manufacturer NVARCHAR(100),
Model NVARCHAR(100),
ChannelCount INT DEFAULT 0,
OwlStatus NVARCHAR(500),
CreateDate DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX IX_VideoExt_Owl (OwlDeviceId),
INDEX IX_VideoExt_Device (DeviceId)
);
CREATE INDEX IX_warehouse_variable_DeviceId ON warehouse_variable (DeviceId);
-- 3. 视频通道表
CREATE TABLE IF NOT EXISTS Video_Channel (
ChannelId CHAR(36) NOT NULL PRIMARY KEY,
OwlChannelId NVARCHAR(64) NOT NULL,
DeviceId CHAR(36) NOT NULL,
ChannelName NVARCHAR(100) NOT NULL,
ChannelNo INT DEFAULT 0,
OwlStreamApp NVARCHAR(50),
OwlStreamName NVARCHAR(100),
HasPtz TINYINT DEFAULT 0,
HasRecording TINYINT DEFAULT 0,
RecordMode INT DEFAULT 0,
IsOnline TINYINT DEFAULT 0,
SnapshotUrl NVARCHAR(500),
Location NVARCHAR(200),
Lat DOUBLE,
Lng DOUBLE,
Enable TINYINT DEFAULT 1,
CreateDate DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX IX_Channel_Owl (OwlChannelId),
INDEX IX_Channel_Device (DeviceId)
);
-- 4. 录像记录表
CREATE TABLE IF NOT EXISTS Video_Record (
RecordId CHAR(36) NOT NULL PRIMARY KEY,
ChannelId CHAR(36) NOT NULL,
OwlRecordId INT NOT NULL,
App NVARCHAR(50),
Stream NVARCHAR(100),
StartedAt DATETIME NOT NULL,
EndedAt DATETIME,
Duration DOUBLE DEFAULT 0,
FilePath NVARCHAR(500),
FileSize BIGINT DEFAULT 0,
CreateDate DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX IX_Record_Channel (ChannelId),
INDEX IX_Record_Time (StartedAt)
);
-- F3.2 规则引擎滞后窗 (hysteresis)
ALTER TABLE warehouse_rulecondition ADD
RecoveryThreshold_Numeric DECIMAL(18,2) NULL,
RecoveryThreshold_Switch NVARCHAR(50) NULL;
-- 5. IoT设备扩展表
CREATE TABLE IF NOT EXISTS Device_IoT_Ext (
ExtId CHAR(36) NOT NULL PRIMARY KEY,
DeviceId CHAR(36) NOT NULL,
Mc4DeviceId INT NOT NULL,
ObjectType INT,
Tag NVARCHAR(100),
ParentId INT,
Mc4Option NVARCHAR(500),
CreateDate DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX IX_IoTExt_Mc4 (Mc4DeviceId),
INDEX IX_IoTExt_Device (DeviceId)
);
-- 6. 设备点位表
CREATE TABLE IF NOT EXISTS IoT_DevicePoint (
PointId CHAR(36) NOT NULL PRIMARY KEY,
DeviceId CHAR(36) NOT NULL,
Mc4DeviceId INT NOT NULL,
PointIndex INT NOT NULL,
PointType INT,
PointTag NVARCHAR(100),
PointName NVARCHAR(100) NOT NULL,
PointDesc NVARCHAR(200),
Unit NVARCHAR(50),
IsControlPoint TINYINT DEFAULT 0,
Mc4Option NVARCHAR(500),
Enable TINYINT DEFAULT 1,
CreateDate DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX IX_Point_Mc4 (Mc4DeviceId, PointIndex),
INDEX IX_Point_Device (DeviceId)
);
-- 7. 设备数据归档表(仅存快照,实时不入库)
CREATE TABLE IF NOT EXISTS IoT_DeviceData (
DataId CHAR(36) NOT NULL PRIMARY KEY,
DeviceId CHAR(36) NOT NULL,
PointId CHAR(36) NOT NULL,
PointValue DOUBLE,
UpdateTime DATETIME NOT NULL,
`Interval` INT DEFAULT 0,
ArchiveType INT DEFAULT 1,
CreateDate DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX IX_Data_Device (DeviceId),
INDEX IX_Data_Time (CreateDate)
);
-- 8. 告警记录表
CREATE TABLE IF NOT EXISTS IoT_Alarm (
AlarmId CHAR(36) NOT NULL PRIMARY KEY,
Mc4AlarmId NVARCHAR(64) NOT NULL,
DeviceId CHAR(36),
PointId CHAR(36),
AlarmType INT DEFAULT 0,
AlarmLevel INT DEFAULT 1,
AlarmDesc NVARCHAR(500),
AlarmValue DOUBLE,
StartTime DATETIME NOT NULL,
EndTime DATETIME,
ConfirmTime DATETIME,
ConfirmUser NVARCHAR(50),
State INT DEFAULT 1,
AdapterCode NVARCHAR(50),
CreateDate DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX IX_Alarm_Mc4 (Mc4AlarmId),
INDEX IX_Alarm_Device (DeviceId),
INDEX IX_Alarm_Time (StartTime)
);
-- F3.3 条件级冷却
ALTER TABLE warehouse_rulecondition ADD
LastTriggered DATETIME NULL,
LastTriggerValue DECIMAL(18,2) NULL;

View File

@@ -0,0 +1,3 @@
严重性 代码 说明 项目 文件 行 抑制状态 详细信息
错误(活动) CS1061 “ControlRequest”未包含“Command”的定义并且找不到可接受第一个“ControlRequest”类型参数的可访问扩展方法“Command”(是否缺少 using 指令或程序集引用?) IntegrationGateway.Host D:\Code\SecMPS\gateway\src\IntegrationGateway.Host\Program.cs 213
错误(活动) CS1061 “ControlRequest”未包含“Parameters”的定义并且找不到可接受第一个“ControlRequest”类型参数的可访问扩展方法“Parameters”(是否缺少 using 指令或程序集引用?) IntegrationGateway.Host D:\Code\SecMPS\gateway\src\IntegrationGateway.Host\Program.cs 213

View File

@@ -0,0 +1,608 @@
# warehouse 客户端代码深度审核报告(含完整修复方案)
> 日期: 2026-06-04 | 项目: warehouse/ | 扫描: 38 源文件, ~12,000 行 | 问题: 70 项
---
## 1. `api/http.js` — 9 项
### H1 [🔴] `lang_storage_key` 未定义
**位置**: 第 134 行
```javascript
function setHeaderLang(_header) {
let langType = localStorage.getItem(lang_storage_key) // ← 未定义!
}
```
**后果**: 运行时报错 `lang_storage_key is not defined`,语言功能完全失效。
**修复** — 在函数上方加常量:
```javascript
const lang_storage_key = 'lang'
```
### H2 [🔴] `replaceToken` 未定义
**位置**: 第 107 行
```javascript
function checkResponse(res) { if (res.headers.vol_exp == '1') { replaceToken() } }
```
**后果**: Token 过期后调用未定义函数,静默失败。
**修复** — 在 `checkResponse` 前追加:
```javascript
function replaceToken() {
store.dispatch('clearUserInfo')
window.location.href = '/login'
}
```
**跨文件影响**: 需确认 `store/index.js``clearUserInfo` mutation已存在
### H3 [🔴] `toLogin` 未定义
**位置**: 第 77 行
```javascript
if (error.response.status == '401') { toLogin() }
```
**修复** — 在文件顶部 import router 后追加:
```javascript
import router from '@/router'
function toLogin() { router.push('/login') }
```
**跨文件影响**: 需确认 `router/index.ts` 导出 router 实例。
### H4 [🟠] 降级地址硬编码
**位置**: 第 25-33 行
```javascript
axios.defaults.baseURL = 'http://192.168.3.108:9100/'
dataViewUrl = 'http://192.168.3.108:9200/'
```
**修复**:
```javascript
axios.defaults.baseURL = window.location.origin
dataViewUrl = (window as any).apiConfig?.dataViewUrl || window.location.origin
```
### H5 [🟠] `get()` 参数 `param` 未使用
**位置**: 第 176 行
```javascript
function get(url, param, loading, config) { axios.get(url, config) }
```
**修复**:
```javascript
function get(url, param, loading, config) {
const cfg = { ...config }
if (param) cfg.params = param
// ... 其余不变
axios.get(url, cfg).then(...)
}
```
### H6 [🟡] `closeLoading` 冗余
**位置**: 第 92-101 行
```javascript
if (loadingInstance) loadingInstance.close()
if (loadingStatus) { loadingStatus = false; if (loadingInstance) loadingInstance.close() }
```
**修复**:
```javascript
loadingStatus = false
loadingInstance?.close()
```
### H7 [🟡] `alert()` 弹窗
**位置**: 第 199 行
```javascript
alert('http.js未配置大屏url地址')
```
**修复**:
```javascript
import { ElMessage } from 'element-plus'
ElMessage.error('未配置大屏URL地址')
```
### H8 [🟡] 无类型安全
**修复** — 在文件头部加 JSDoc不改变运行时:
```typescript
/**
* @template T
* @param {string} url
* @param {object} [params]
* @param {boolean|string} [loading]
* @param {object} [config]
* @returns {Promise<T>}
*/
function post(url, params, loading, config) { ... }
```
### H9 [⚪] 文件臃肿 (409行)
**修复** — 拆为 3 文件:
- `api/http-client.ts` — Axios 实例 + baseURL + 拦截器 (80行)
- `api/http-auth.ts` — getToken/replaceToken/toLogin (40行)
- `api/http-loading.ts` — showLoading/closeLoading (20行)
`api/http.js` 改为:
```javascript
import { createHttpClient } from './http-client'
export default createHttpClient()
```
---
## 2. `api/gateway.ts` — 3 项
### GW1 [🟠] 网关地址硬编码
**位置**: 第 5 行 `const GW_BASE = 'http://192.168.3.108:5100'`
**修复**:
```typescript
const GW_BASE = (window as any).apiConfig?.gatewayUrl || 'http://localhost:5100'
```
### GW2 [🟡] `fetch()` 无超时
**修复** — 完整重写 `gwGet``gwPost` 同理):
```typescript
export async function gwGet(url: string, timeoutMs = 10000): Promise<any> {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
try {
const resp = await fetch(`${GW_BASE}${url}`, { signal: ctrl.signal })
if (!resp.ok) throw new Error(`网关请求失败: ${resp.status}`)
return resp.json()
} finally { clearTimeout(timer) }
}
```
### GW3 [🟡] 模型映射混入API层
**修复** — 新建 `warehouse/src/services/cameraService.ts`:
```typescript
import { gwGet, type Camera, type StandardDevice } from '@/api/gateway'
export function toCamera(d: StandardDevice): Camera { ... }
export async function fetchCameras(adapter: string): Promise<Camera[]> { ... }
```
**跨文件影响**: `Live.vue`, `VideoWall.vue`, `History.vue` 的 import 改为 `from '@/services/cameraService'`
---
## 3. `api/buttons.js` — 1 项
### B1 [🟡] Element UI 旧版图标语法
```javascript
icon: 'el-icon-search'
```
Element Plus ≥2.0 已废弃字符串图标。
**修复** — 改为组件引用:
```javascript
import { Search, Plus, Edit, DocumentCopy, Delete, Check, Finished, Top, Bottom, Printer } from '@element-plus/icons-vue'
import { shallowRef } from 'vue'
const buttons = [
{ name:'查询', icon: shallowRef(Search), ... },
{ name:'新建', icon: shallowRef(Plus), ... },
// ... 其余同理
]
```
**跨文件影响**: 使用 buttons 的组件需确认其渲染逻辑支持组件引用(通常通过 `<component :is="btn.icon" />`)。
---
## 4. `api/permission.js` — 2 项
### PE1 [🟠] 权限缺失时静默放行
**位置**: 第 24 行
```javascript
if (!permission) { permission = { permission: ['Search'] } }
```
**修复**:
```javascript
if (!permission) { return [] }
```
空数组使所有按钮不可见(安全默认)。
### PE2 [🟡] `to401` 空实现
**修复**:
```javascript
import router from '@/router'
function to401() { router.push('/401') }
```
---
## 5. `router/index.ts` — 5 项
### R1 [🟠] 40+ 条路由指向同一组件
所有菜单子项渲染 `Index.vue`,用户看到重复空白页。
**修复** — 3 步方案:
1. 为已实现的页面保留独立路由VideoWall/AlarmRecord/AccessRecord 等已存在)
2. 其余指向一个占位组件:
```typescript
{ path: "/index/goods/list", component: () => import("@/view/Placeholder.vue") }
```
3. `Placeholder.vue`:
```vue
<template><el-empty description="功能开发中" /></template>
```
### R2 [🟡] `/new-dv` 不要求认证
```typescript
{ path:"/new-dv", meta:{ requiresAuth: false } }
```
**修复**: 改为 `requiresAuth: true`
### R3 [🟡] 缺少 beforeEach 守卫
**修复** — 在 `router/index.ts` 末尾追加:
```typescript
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth !== false) {
const token = localStorage.getItem('token')
if (token) next()
else next('/login')
} else { next() }
})
```
### R4 [⚪] `@ts-ignore` 绕过 store 类型
**修复**: 创建 `warehouse/src/types/store.d.ts`:
```typescript
declare module '@/store' {
const store: import('vuex').Store<any>
export default store
}
```
### R5 [⚪] 仓库/货物/出入库路由 20+ 条未使用
**修复**: 删除。若后续需要可从 git 恢复。
---
## 6. `main.ts` — 1 项
### M1 [🟡] 暗色模式写死
```typescript
app.use(ElementPlus, { dark: true })
```
**修复**:
```typescript
const dark = localStorage.getItem('dark-mode') !== 'false'
app.use(ElementPlus, { dark })
```
---
## 7. `view/index.js` — 6 项
### SI1 [🔴] `displayedMessageIds` Set 无限增长
**位置**: 第 8 行 `let displayedMessageIds = new Set()`
**后果**: 运行数天后 Set 包含成千上万条 ID → 内存泄漏。
**修复** — 改为 LRU 缓存(保留最近 500 条):
```javascript
class LruSet {
#set = new Set()
#max = 500
add(v) { if (this.#set.has(v)) return; this.#set.add(v); if (this.#set.size > this.#max) { this.#set.delete(this.#set.values().next().value) } }
has(v) { return this.#set.has(v) }
}
const displayedMessageIds = new LruSet()
```
### SI2 [🟠] 消息队列 3s 延迟堆积
**修复**: 删除 `messageQueue`/`processMessageQueue`,直接调 `receive`:
```javascript
connection.on("ReceiveHomePageMessage", function (data) {
if (displayedMessageIds.has(data.id)) return
displayedMessageIds.add(data.id)
receive(data)
})
```
### SI3 [🟠] connection 启动无重试
`connection.start().catch(...)` 失败后永远不重试。
**修复**:
```javascript
async function startWithRetry(retries = 5) {
for (let i = 0; i < retries; i++) {
try { await connection.start(); return }
catch (e) { console.warn(`SignalR retry ${i+1}/${retries}: ${e.message}`); await new Promise(r => setTimeout(r, 2000)) }
}
console.error('SignalR connection failed after retries')
}
startWithRetry()
```
### SI4 [🟡] `console.log` 残留 6 处
**修复**: 全部替换为 `if (import.meta.env.DEV) console.log(...)`
### SI5 [🟡] `receive` 被双重调用
`processMessageQueue``ReceiveHomePageMessage` 都调了 `receive`
**修复**: SI2 删除了 `processMessageQueue` 后此问题自动消除。
### SI6 [🟡] 用户信息获取失败静默
**修复**:
```javascript
http.post("api/user/GetCurrentUserInfo").then(...).catch(error => {
console.error('获取用户信息失败SignalR未启动:', error)
})
```
---
## 8-28: 视图层文件21 个 Vue 文件)
### 通用修复模式(适用于所有 Mock 页面)
以下 17 个页面全 Mock —— 统一修复方案:
```
AccessRecord.vue, AlarmRecord.vue, EmergencyAlarmRecord.vue,
KeyInfo.vue, KeyApply.vue, EnvVarManagement.vue,
PatrolLog.vue, ScheduleManagement.vue, PathManagement.vue,
DroneManagement.vue, dataview.vue, CarApply.vue, CarManager.vue,
DeviceStatus.vue(V), VisitorsManagement.vue, VisitCarManagement.vue
```
**统一修复**: 每个页面增加网关 API 调用骨架Mock 数据降级为 fallback:
```typescript
// 以 AlarmRecord.vue 为例
import { gwGet } from '@/api/gateway'
const fetchData = async () => {
try {
const data = await gwGet('/api/gateway/alarms/Owl:main?page=1&size=100')
alarmData.value = data.items.map((a: StandardAlarm) => ({
id: a.alarmId, alarmTime: a.occurTime, deviceName: a.title,
location: a.deviceId, status: a.status, imageUrl: '/images/placeholder.png'
}))
} catch {
alarmData.value = getMockAlarmData() // fallback
}
}
```
### 单个页面专项修复
**DataView.vue** (1840行):
- DV1: 告警等级 → 改用 `level` 字段或网关 `StandardAlarm.level`
- DV2: `setTimeout``clearTimeout`:
```typescript
const timers = new Set<number>()
onBeforeUnmount(() => timers.forEach(t => clearTimeout(t)))
// 使用时: timers.add(setTimeout(...))
```
- DV6: `originalData: JSON.parse(JSON.stringify(data))` 避免循环引用
**DeviceInfo.vue** (1300行):
- DI1: 对接网关 B4 获取真实在线率
- DI2: `randomVideoImage` → `gwGet('/api/gateway/streams/.../live')`
- DI3: `handleTurnOn` → `gwPost('/api/gateway/realtime/.../control', { deviceId, pointIndex, value })`
**Live.vue** / **VideoWall.vue** / **History.vue**:
- LV2/VH2: `setInterval` 加清理:
```typescript
const timer = setInterval(updateTime, 1000)
onBeforeUnmount(() => clearInterval(timer))
```
**Main.vue**:
- MA1: icon 改为 `@element-plus/icons-vue` 组件引用
- MA2: Menu 配置提取:
```typescript
const menuItems = [
{ index:'1', icon: VideoCamera, label:'视频监控', children:[
{ index:'/index/video/videowall', label:'视频墙' }
]}
]
```
- MA5: 统一 `import { useMapStore } from '@/stores/mapStore'`
**Map.vue**:
- MP1: `const m = /#\/(\d+)/.exec(location.hash); const mapId = m?.[1] || 'default'`
**Index.vue**:
- IN1: `const mapId = import.meta.env.VITE_MAP_ID || 'default'`
---
## 29-31: 组件层3 文件)
### Filter.vue [🟡]
**console.log 8 处**: 同 SI4改为条件输出。
**硬件设备图标映射** 提取为常量:
```typescript
const DEVICE_ICONS: Record<string, string> = {
'摄像头':'/images/dataview/deviceinfo/camera.png',
'门禁':'/images/dataview/deviceinfo/access.png',
// ...
}
```
### Fence.vue [🟡]
**硬编码仓库名**: `['1号库','2号库','12号库']`
**修复**: 从 store 或配置注入:
```typescript
const warehouseNames = inject('warehouseNames', ['1号库'])
const inFencePoints = store.polygonDataAll.filter(p => warehouseNames.includes(p.name))
```
---
## 32-34: 状态管理层3 文件)
### SM1 [🔴] 两份 `useMapStore` 同名
**文件**: `stores/mapStore.js` 和 `store/useMapStore.js` 都 `defineStore('map', ...)`
**修复** — 选择一份保留(推荐 `stores/mapStore.js` 功能更全),另一份删除:
```bash
git rm warehouse/src/store/useMapStore.js
```
**跨文件影响** — 修改以下文件的 import:
- `Map.vue` line 9: `'../store/useMapStore'` → `'../stores/mapStore'`
- `Index.vue` line 10: `'../stores/mapStore'` (已对)
- `Fence.vue` line 4: `'../store/useMapStore'` → `'../stores/mapStore'`
- `Filter.vue` line 3: `'../store/useMapStore'` → `'../stores/mapStore'`
- `DataView.vue` line 10: `'../stores/mapStore'` (已对)
### ST1 [🟠] `getServiceList` getter 忽略参数
**位置**: `store/index.js`
```javascript
getServiceList: (state) => (path) => { return state.serviceList || [] }
```
**修复**: 如果不需要按 path 过滤则简化为:
```javascript
getServiceList: (state) => state.serviceList || []
```
### ST3 [🟡] `test` mutation 返回 `113344`
**位置**: `store/index.js` line 49
```javascript
test(state) { return 113344 } // 调试代码
```
**修复**: 删除此 mutation。
### ST4 [⚪] `setPermission` 数组 push 会叠加
```javascript
if (data instanceof Array) { state.permission.push(...data) }
```
每次调用追加而非替换。**修复**: 始终替换 `state.permission = data`。
---
## 35: 项目结构 — 4 项
### PS1/PS2 [🟡] 过期副本文件
```bash
git rm warehouse/src/view/DataView\ copy.vue
git rm warehouse/src/view/Map.vue.bak
```
### PS3 [🟡] 文档放在源码目录
```bash
mkdir -p warehouse/doc
mv warehouse/src/view/intercom/TODO_*.md warehouse/doc/
```
### PS4 [🟠] Vuex + Pinia 共存
**修复** — 迁移 Vuex → Pinia:
1. 新建 `stores/authStore.js`(替代 Vuex 的 userInfo/token/permission
2. 迁移 `store/index.js` 中的 `setUserInfo`/`getToken`/`getPermission` 到 Pinia
3. `npm uninstall vuex`
4. 修改 `http.js`/`Login.vue`/`permission.js` 从 `@/stores/authStore` 导入
---
## 36: 全局问题 — 7 项
### G1 [🟠] Mock 覆盖率 86%
22 页面中仅 3 个对接网关。**分 4 阶段对接**:
| 阶段 | 页面 | 网关接口 | 预计 |
|:--:|------|------|:--:|
| 1 | 告警页 (AlarmRecord/EmergencyAlarm) | B8 (GET /alarms) | 2h |
| 2 | 环境变量 (EnvVarManagement) | B4 (GET /realtime) | 2h |
| 3 | 门禁/钥匙 (AccessRecord/KeyInfo) | B2 (GET /devices) + B11 (GET /logs) | 3h |
| 4 | 巡更/无人机/车辆/访客 | B2 设备列表 | 1h |
### G2 [🟡] 硬编码IP散布6+文件
**修复** — 创建 `.env.development`:
```
VITE_GATEWAY_URL=http://localhost:5100
VITE_VOLPRO_URL=http://localhost:9100
```
各文件改为:
```typescript
const GW_BASE = import.meta.env.VITE_GATEWAY_URL || 'http://localhost:5100'
```
### G3 [🟡] JS/TS 混用
**修复**: 7 个 `.js` → `.ts`(逐文件迁移,不改运行时逻辑):
```
api/http.js → api/http.ts
api/buttons.js → api/buttons.ts
api/permission.js → api/permission.ts
view/index.js → view/index.ts
stores/mapStore.js → stores/mapStore.ts
router/viewGird.js → router/viewGird.ts
```
### G4 [🟡] `window.*` 全局变量
**修复**: `window.$map` → Store 管理(已存在 `mapStore.setMap`
```typescript
// Map.vue: 删除 window.$map = map保留 store.setMap(map)
// 其他文件: 改 window.$map → const { map } = useMapStore()
```
### G5 [🟡] `console.log` 残留 30+ 处
**修复** — 一行全局替换:
```bash
# 将所有 console.log 改为条件输出
find warehouse/src -name "*.vue" -o -name "*.ts" -o -name "*.js" | xargs sed -i 's/console\.log(/if(import\.meta\.env\.DEV)console.log(/g'
```
### G6 [⚪] 无全局错误边界
**修复** — `main.ts`:
```typescript
app.config.errorHandler = (err) => {
console.error('Global error:', err)
ElMessage.error('系统异常,请刷新页面')
}
```
### G7 [⚪] `counter.ts` 模板残留
**修复**: 删除 `warehouse/src/stores/counter.ts`。
---
## 执行优先级
| 批次 | 项 | 文件 | 预计 | 影响 |
|:--:|------|------|:--:|------|
| 🔴P0 | H1+H2+H3+SI1+SM1 | 5 | 2h | 修复运行时错误 |
| 🟠P1 | SI3+PE1+R1+R3+G1(告警) | 6 | 4h | 功能可用性 |
| 🟡P2 | GW1+GW2+H5+H6+DV1+LV2 | 6 | 3h | 代码质量 |
| ⚪P3 | PS+console+G6+CT1 | 5 | 1h | 整洁性 |
> **总计**: 70 项 / 预估 10h。P0 批 5 项必须在联调前完成。

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1,256 @@
# 钥匙柜KMS整合方案 v1.0
> **版本**: 1.0
> **日期**: 2025-05-19
> **数据源**: doc/对接文档/钥匙管理系统软件接口.docx
> **架构**: IntegrationGateway 适配器模式 + Vol.Pro 管理端
---
## 1. 现状分析
### 1.1 KMS 系统概览
钥匙管理系统是一个独立的 REST API 服务,管理智能钥匙柜的硬件设备。
| 项目 | 说明 |
|------|------|
| 技术栈 | Java/Spring Boot (推测) |
| 认证方式 | clientId/clientSecret → Bearer Token (30分钟) |
| 数据模型 | 柜体(locker) → 锁孔(lockhole) → 钥匙(opener) |
| 用户体系 | 员工(staff) + 员工组(staff group) |
| 核心业务 | 借还记录、交接记录、远程授权、告警记录 |
### 1.2 KMS 第三方集成接口第2.18节)
KMS 预留了 8 个专用于第三方对接的接口:
| 接口 | 路径 | 用途 |
|------|------|------|
| 心跳 | `/prod-api/kms/thirdparty/heartbeat` | 检测 KMS 存活 |
| 批量删除员工 | `/prod-api/kms/staff/batchDelete` | 同步删除 |
| 批量同步员工 | `/prod-api/kms/staff/batchSync` | 同步员工信息 |
| 查询柜体钥匙 | `/prod-api/kms/thirdparty/locker/keys` | 获取所有柜体及其钥匙列表 |
| 授权记录 | `/prod-api/kms/thirdparty/auth/records` | 查询远程授权历史 |
| 借还记录 | `/prod-api/kms/thirdparty/borrow/records` | 查询借还日志 |
| 告警记录 | `/prod-api/kms/thirdparty/alarm/records` | 查询告警列表 |
| 事件记录 | `/prod-api/kms/thirdparty/event/records` | 查询系统事件 |
认证方式:所有接口需要在 `Authorization: Bearer <token>` 头中携带 Token。
### 1.3 Vol.Pro 端现存钥匙相关模块
| 表/模块 | 说明 | 来源 |
|----------|------|------|
| `warehouse_keys` | 钥匙管理(自主) | Vol.Pro 代码生成 |
| `warehouse_keyapply` | 钥匙领用申请 | Vol.Pro 代码生成 |
| `warehouse_keylog` | 钥匙使用日志 | Vol.Pro 代码生成 |
这些是 Vol.Pro 自建的钥匙管理流程(申请→审批→记录),与硬件 KMS 系统平行运行。
---
## 2. 整合策略
### 2.1 核心决策
**KMS 作为一个独立的物联网子系统接入 IntegrationGateway**,与 Owl、MC4.0 平级。
通过适配器模式KMS 的能力被抽象为统一的网关接口Vol.Pro 管理端通过网关调用 KMS无需直接对 KMS 编程。
### 2.2 KMS 能力评估
| 能力 | KMS 支持 | 统一接口 | 实现优先级 |
|------|:---:|------|:---:|
| 设备列表 | ✅ (钥匙柜+钥匙) | `IHasFlatDevices` | Phase 1 |
| 告警 | ✅ (告警记录) | `IHasAlarms` | Phase 1 |
| 实时状态 | ❌ | 无 | — |
| 远程控制 | ✅ (远程授权/开门) | `IAcceptsControl` (新增) | Phase 2 |
| 借还记录 | ✅ | 新增接口 | Phase 2 |
| 员工同步 | ✅ | 无 (单向推送) | Phase 2 |
### 2.3 设备模型映射
KMS 的物理拓扑是 **柜体 → 锁孔 → 钥匙**。映射到 `StandardDevice`
```
KMS 柜体 (locker) → StandardDevice { DeviceGroup="门禁设备", DeviceCategory="智能钥匙柜", IsParent=true }
KMS 锁孔 (lockhole) → StandardDevice { DeviceGroup="门禁设备", DeviceCategory="钥匙位", IsParent=false, ParentSourceId=柜体SourceId }
(钥匙本身是一个逻辑实体,不映射为设备)
```
**示例**
```
KMS "10位智能公共钥匙柜" (lockerId=25)
├── 钥孔1 "仓库大门钥匙" (lockholeSort=1)
├── 钥孔2 "机房钥匙" (lockholeSort=2)
├── ...
└── 钥孔10 "配电室钥匙" (lockholeSort=10)
```
每个锁孔又是一个 `StandardDevice`,其 `Extra` 字段承载钥匙状态(在位/离位/借出)。
---
## 3. 网关改造
### 3.1 新增 `IntegrationGateway.Adapters.Kms`
```
gateway/src/IntegrationGateway.Adapters.Kms/
├── KmsAdapter.cs # IHasFlatDevices + IHasAlarms
└── KmsAuthHelper.cs # clientId/clientSecret → Bearer Token
```
### 3.2 KmsAuthHelper
```csharp
/// 认证流程: POST /prod-api/kms/token?clientId=xxx&clientSecret=yyy → { code, token }
/// Token 有效期 30 分钟, 过期前 5 分钟自动刷新
public class KmsAuthHelper
{
public async Task<string> GetTokenAsync() { ... }
public async Task<HttpClient> GetAuthenticatedClientAsync() { ... }
}
```
### 3.3 KmsAdapter 能力接口
```csharp
public class KmsAdapter : IHasFlatDevices, IHasAlarms
{
public string AdapterCode { get; } // "KMS:main"
public AdapterCapabilities Capabilities => new()
{
HasFlatDevices = true, HasAlarms = true
};
// IHasFlatDevices: 查询柜体钥匙信息 → List<StandardDevice>
public async Task<PagedResult<StandardDevice>> GetDevicesAsync(int page, int size, string? keyword)
{
// 调 2.18.4 GET /prod-api/kms/thirdparty/locker/keys
// 展开为: 每个柜体 → 1个父设备, 每个锁孔 → 1个子设备
}
// IHasAlarms: 查询告警记录
public async Task<PagedResult<StandardAlarm>> GetAlarmsAsync(...)
{
// 调 2.18.7 GET /prod-api/kms/thirdparty/alarm/records
// KMS 告警类型: 1=当前告警, 2=历史告警
}
public async Task ConfirmAlarmAsync(string alarmId) { ... }
public async Task EndAlarmAsync(string alarmId) { ... }
}
```
### 3.4 配置新增
```json
// appsettings.json
{
"KMS": {
"InstanceName": "main",
"BaseUrl": "http://192.168.1.50:8080",
"ClientId": "your_client_id",
"ClientSecret": "your_client_secret"
}
}
```
### 3.5 Program.cs 注册
```csharp
var kmsList = app.Configuration.GetSection("KMS").Get<List<KmsConfig>>() ?? new();
foreach (var k in kmsList)
registry.Register(new KmsAdapter($"KMS:{k.InstanceName ?? "default"}", http, k.BaseUrl, k.ClientId, k.ClientSecret));
```
---
## 4. Vol.Pro 管理端改动
### 4.1 数据流向
```
KMS 硬件柜 IntegrationGateway Vol.Pro
─────── ────────────── ──────
钥匙在位/离位 ────→ KmsAdapter.GetDevices ────→ base_device 表
(AdapterCode="KMS:main")
(DeviceCategory="钥匙位")
告警事件 ────→ KmsAdapter.GetAlarms ────→ iot_alarm 表
远程授权开门 ←──── B-interface (Phase2) ←──── 管理端操作
```
### 4.2 不需要改的内容
- `warehouse_keys` / `warehouse_keyapply` / `warehouse_keylog` — 保留现有体系,不与 KMS 冲突
- `base_device` 表 — 已支持 `DeviceCategory="钥匙位"``DeviceGroup="门禁设备"`
- 前端操作列 — 已预留 `AccessDeviceActions` / `AlarmDeviceActions` 骨架
- 管理端设备列表 — 自动显示 KMS 同步的设备
### 4.3 需要新增的内容
| 项 | 说明 |
|------|------|
| KMS 数据字典项 | `设备种类` 字典增加 "智能钥匙柜"、"钥匙位" |
| 前端 KMS 操作按钮 | `KeyDeviceActions.vue` — 显示钥匙状态 + 远程授权入口 (Phase 2) |
---
## 5. 数据映射表
### 5.1 KMS 柜体 → StandardDevice
| KMS 字段 | StandardDevice 字段 |
|------|------|
| lockerId | SourceId |
| lockerName | Name |
| "智能钥匙柜" | Category |
| "门禁设备" | Group |
| true | IsParent |
| lockerCode | Extra.lockerCode |
| online (健康检查) | IsOnline |
### 5.2 KMS 锁孔 → StandardDevice
| KMS 字段 | StandardDevice 字段 |
|------|------|
| lockerId.lockholeSort | SourceId (组合) |
| openerName (钥匙名) | Name |
| "钥匙位" | Category |
| "门禁设备" | Group |
| false | IsParent |
| lockerId | ParentSourceId |
| openerState (在位/离位) | Extra.openerState |
| openerType (永久授权/一次性授权/应急授权) | Extra.openerType |
### 5.3 KMS 告警 → StandardAlarm
| KMS 字段 | StandardAlarm 字段 |
|------|------|
| uuid | AlarmId (SourceAlarmId) |
| warningTime | OccurTime |
| alarmType 映射 | Level (提示/普通/重要) |
| lockerName + openerName | Title |
| remark | Content |
---
## 6. 实施计划
| 阶段 | 内容 | 预计工时 |
|------|------|:---:|
| K1 | 创建 `IntegrationGateway.Adapters.Kms` 项目骨架 | 0.5h |
| K2 | 实现 `KmsAuthHelper` (clientId/secret → Token) | 1h |
| K3 | 实现 `KmsAdapter``IHasFlatDevices` (设备同步) | 2h |
| K4 | 实现 `KmsAdapter``IHasAlarms` (告警同步) | 1h |
| K5 | 网关配置 + Program.cs 注册 | 0.5h |
| K6 | 字典补充(智能钥匙柜/钥匙位) | 0.5h |
| K7 | 联调验证 (需 KMS 环境) | 2h |
| K8 | Phase 2: 远程授权/开门 + 前端按钮 | 3h |
---
> **风险**: KMS 的实际 API 路径和响应格式需在真实环境验证,文档中的路径格式可能与实际部署有差异(如 `/prod-api/kms/` 前缀可能变化)。

View File

@@ -0,0 +1,256 @@
# 钥匙柜KMS整合方案 v2.0
> **版本**: 2.0
> **日期**: 2025-05-19
> **数据源**: `doc/对接文档/钥匙管理系统软件接口.docx`KMS API v1.0.4
> **技术栈**: 全链路 .NET 8 / C#(网关 ASP.NET Core + Vol.Pro ASP.NET Core
> **架构**: IntegrationGateway 适配器模式KMS 作为第三个子系统适配器加入
---
## 1. KMS 系统全接口分析
### 1.1 认证体系
| 项目 | 说明 |
|------|------|
| 认证接口 | `POST /prod-api/getToken` |
| 参数 | `clientId` + `clientSecret`(由 KMS 管理员分配) |
| 返回 | `{ code: 200, token: "xxx" }` |
| 有效期 | 30 分钟,过期重新获取 |
| 使用方式 | 后续请求 Header: `Authorization: Bearer <token>` |
### 1.2 第三方专用接口(第 2.18 节 —— 整合核心)
KMS 预留了 8 个专供第三方对接的接口,这些是网关适配器必须实现的:
| # | 方法 | 路径 | 用途 | 对应能力接口 |
|---|:---:|------|------|------|
| 2.18.1 | `POST` | `/prod-api/heartBeat` | 心跳检测 | `IGatewayAdapter.HealthCheck` |
| 2.18.2 | `POST` | `/prod-api/batchDeleteStaff` | 批量删除员工 | Phase 2 |
| 2.18.3 | `POST` | `/prod-api/batchSyncStaff` | 批量同步员工信息 | Phase 2 |
| 2.18.4 | `POST` | `/prod-api/getOpenerList` | 查询所有柜体及钥匙列表 | `IHasFlatDevices` |
| 2.18.5 | `POST` | `/prod-api/getPermissionList` | 查询远程授权记录 | Phase 2 |
| 2.18.6 | `POST` | `/prod-api/getRecordList` | 查询借还记录 | Phase 2 |
| 2.18.7 | `POST` | `/prod-api/getWarningList` | 查询告警记录 | `IHasAlarms` |
| 2.18.8 | `POST` | `/thirdPlatlogin` | 第三方登录/事件记录 | Phase 2 |
> 注意:第 2.18.X 节接口的路径不同于标准 KMS 业务接口(不以 `/prod-api/kms/` 开头)。这是 KMS 为第三方特意设计的**扁平化集成 API**。
### 1.3 标准 KMS 业务接口(第 2.3-2.17 节 —— 辅助参考)
以下是 KMS 完整的标准 REST API供深入对接时使用
| 模块 | 接口 | 方法 | 说明 |
|------|------|:---:|------|
| **认证** | `/prod-api/getToken` | POST | 获取 Bearer Token |
| **部门** | `/prod-api/system/dept/root/{userId}` | GET | 获取部门树 |
| **交接记录** | `/prod-api/kms/handover/handoverInfolist` | GET | 查询交接记录明细 |
| | `/prod-api/kms/handover/list` | GET | 查询交接记录列表(分页) |
| **授权** | `/prod-api/kms/permission/list` | GET | 查询授权列表 |
| | `/prod-api/kms/permission/listPer` | GET | 查询授权人列表 |
| | `/prod-api/kms/permission/remote` | POST | 远程授权 |
| **告警** | `/prod-api/kms/warning/list` | GET | 查询告警列表(标准版) |
| **员工可借** | `/prod-api/kms/staffopener/available` | POST | 设置员工可借/永久授权钥匙 |
| | `/prod-api/kms/staffopener/listall` | GET | 查询员工可借/永久授权钥匙列表 |
| **员工管理** | `/prod-api/kms/staff` | POST | 创建员工 |
| | `/prod-api/kms/staff` | PUT | 修改员工信息 |
| | `/prod-api/kms/staff/list` | GET | 分页查询员工列表 |
| | `/prod-api/kms/staff/{id}` | DELETE | 删除员工 |
| | `/prod-api/kms/staff/{id}` | GET | 获取员工详细信息 |
| **员工组** | `/prod-api/kms/staffGroup/...` | CRUD | 员工组管理6个接口 |
| **物品类别** | `/prod-api/kms/openerType/...` | CRUD | 物品类别管理6个接口 |
| **柜体管理** | `/prod-api/kms/locker` | POST/PUT | 创建/修改柜体 |
| | `/prod-api/kms/locker/list` | GET | 分页查询柜体列表 |
| | `/prod-api/kms/locker/{id}` | DELETE/GET | 删除/获取柜体详细信息 |
| | `/prod-api/kms/locker/statistics` | GET | 首页统计图表数据 |
| **锁孔管理** | `/prod-api/kms/lockhole` | PUT | 修改锁孔 |
| | `/prod-api/kms/lockhole/list` | GET | 分页查询锁孔列表 |
| | `/prod-api/kms/lockhole/{id}` | DELETE/GET | 删除/获取锁孔详细信息 |
| **钥匙管理** | `/prod-api/kms/opener` | POST/PUT | 创建/修改钥匙 |
| | `/prod-api/kms/opener/list` | GET | 分页查询钥匙列表 |
| | `/prod-api/kms/opener/selectCanBorrow` | GET | 查询可借钥匙列表 |
| | `/prod-api/kms/opener/staff` | GET | 查询可借钥匙员工列表 |
| | `/prod-api/kms/opener/{id}` | DELETE/GET | 删除/获取钥匙详细信息 |
| **钥匙组** | `/prod-api/kms/openerGroup/...` | CRUD | 钥匙组管理7个接口 |
> 共约 **50+ 个 REST 端点**,覆盖 KMS 完整业务。
---
## 2. 整合策略
### 核心原则
1. **KMS 作为独立子系统**,通过 IntegrationGateway 的 `KmsAdapter` 接入
2. **Phase 1** 实现第三方接口8 个端点),这是 KMS 专门为集成设计的
3. **Phase 2** 按需扩展标准业务接口(远程授权/开门/借还记录查询)
4. Vol.Pro 管理端通过网关 B 组接口间接调用 KMS不直接对 KMS 编程
### 适配器能力矩阵
| 能力接口 | 实现 | 映射的 KMS 第三方接口 |
|----------|:---:|------|
| `IGatewayAdapter` | ✅ | 2.18.1 心跳 |
| `IHasFlatDevices` | ✅ | 2.18.4 柜体钥匙列表 → StandardDevice |
| `IHasAlarms` | ✅ | 2.18.7 告警列表 → StandardAlarm |
| 远程控制 (新接口) | ⏭️ | 2.18.5 远程授权 + KMS 标准接口 remote |
---
## 3. 设备树映射
KMS 物理拓扑:**柜体(Locker) → 锁孔(Lockhole) → 钥匙(Opener)**
映射到 `base_device` 表:
```
KMS 管理平台 (一个 IP:PORT = 一个 gateway_node)
├── 智能钥匙柜A (lockerId=25)
│ ├── 锁孔1 "仓库大门" (lockholeSort=1 → StandardDevice, Category="钥匙位")
│ ├── 锁孔2 "机房钥匙" (lockholeSort=2)
│ └── 锁孔N ...
├── 智能钥匙柜B (lockerId=26)
│ ├── 锁孔1 "配电室"
│ └── 锁孔M ...
```
| KMS 实体 | base_device 字段 | 值 |
|----------|------|------|
| 柜体 | `SourceId` | `locker_{lockerId}` |
| | `Name` | lockerName (如 "10位智能公共钥匙柜") |
| | `DeviceCategory` | "智能钥匙柜" |
| | `DeviceGroup` | "门禁设备" |
| | `IsParent` | "是" |
| | `Extra` | `{ lockerCode, lockholeCount }` |
| 锁孔子设备 | `SourceId` | `lockhole_{lockerId}_{lockholeSort}` |
| | `Name` | openerName (如 "仓库大门钥匙") |
| | `DeviceCategory` | "钥匙位" |
| | `DeviceGroup` | "门禁设备" |
| | `IsParent` | "否" |
| | `ParentSourceId` | `locker_{lockerId}` (解析为 ParentDeviceId) |
| | `Extra` | `{ openerState, openerType, openerId }` |
| | `IsOnline` | openerState="在位" → 在线; "离位" → 离线 |
---
## 4. 告警映射
| KMS 告警字段(2.18.7) | StandardAlarm 字段 | 说明 |
|------|------|------|
| uuid | SourceAlarmId | KMS 告警唯一ID |
| warningTime | OccurTime | 告警时间 |
| type (1=当前告警,2=历史告警) | Status | type=1→"未确认", type=2→"已结束" |
| lockerName + lockholeSort | Title | 如 "10位公共钥匙柜 锁孔3" |
| openerName | 关联设备 | 通过 openerId 查找对应设备 |
| remark | Content | 告警备注详情 |
---
## 5. 网关改造清单
### 5.1 新增项目
```
gateway/src/IntegrationGateway.Adapters.Kms/
├── IntegrationGateway.Adapters.Kms.csproj
├── KmsAdapter.cs # IHasFlatDevices + IHasAlarms
├── KmsAuthHelper.cs # clientId/clientSecret → Bearer Token
└── KmsModels.cs # KMS 响应 DTO
```
### 5.2 KmsAuthHelper
```csharp
/// .NET 8 ASP.NET Core 适配: KMS Bearer Token 认证
public class KmsAuthHelper
{
private readonly HttpClient _http;
private readonly string _baseUrl, _clientId, _clientSecret;
private string? _token;
private DateTime _expiry = DateTime.MinValue; // 30min TTL
public async Task<string> GetTokenAsync() { ... }
public async Task<HttpClient> GetAuthenticatedClientAsync() { ... }
}
```
### 5.3 KmsAdapter
```csharp
public class KmsAdapter : IHasFlatDevices, IHasAlarms
{
public string AdapterCode { get; } // "KMS:main"
public AdapterCapabilities Capabilities => new() {
HasFlatDevices = true, HasAlarms = true
};
// IHasFlatDevices → 2.18.4 POST /prod-api/getOpenerList
public async Task<PagedResult<StandardDevice>> GetDevicesAsync(int page, int size, string? keyword);
// IHasAlarms → 2.18.7 POST /prod-api/getWarningList
public async Task<PagedResult<StandardAlarm>> GetAlarmsAsync(int page, int size, DateTime from, DateTime to, string? level, string? state);
public async Task ConfirmAlarmAsync(string alarmId);
public async Task EndAlarmAsync(string alarmId);
// IGatewayAdapter
public async Task<bool> HealthCheckAsync(); // → 2.18.1 POST /prod-api/heartBeat
}
```
### 5.4 配置文件
```json
// appsettings.json 新增 KMS 段
{
"KMS": {
"InstanceName": "main",
"BaseUrl": "http://192.168.1.50:8080",
"ClientId": "your_client_id",
"ClientSecret": "your_client_secret"
}
}
```
### 5.5 Program.cs 注册
```csharp
var kmsList = app.Configuration.GetSection("KMS").Get<List<KmsConfig>>() ?? new();
foreach (var k in kmsList)
registry.Register(new KmsAdapter($"KMS:{k.InstanceName ?? "default"}",
httpClient, k.BaseUrl, k.ClientId, k.ClientSecret));
```
---
## 6. Vol.Pro 端改动
| 项 | 改动 | 说明 |
|------|:---:|------|
| 数据库 | 无 | base_device/iot_alarm 已兼容 |
| 后端代码 | 无 | A1-A4 同步逻辑通用 |
| 字典 | 新增 2 项 | "智能钥匙柜" / "钥匙位" 加入设备种类字典 |
| 前端页面 | 无 | 设备列表自动显示 KMS 同步的设备 |
| 前端操作按钮 | Phase 2 | `KeyDeviceActions.vue` — 显示钥匙状态 + 远程授权入口 |
---
## 7. 实施计划
| 阶段 | 内容 | 涉及文件 | 预计 |
|------|------|------|:---:|
| K0 | 创建 `Adapters.Kms` 项目 + 引用 Core | csproj + sln | 10min |
| K1 | `KmsModels.cs` — 所有 KMS 响应 DTO | 1 文件 | 30min |
| K2 | `KmsAuthHelper.cs` — Token 获取 + 刷新 | 1 文件 | 30min |
| K3 | `KmsAdapter.HealthCheck` + `GetDevicesAsync` | 1 文件 | 1h |
| K4 | `KmsAdapter.GetAlarmsAsync` + Confirm/End | 1 文件 | 1h |
| K5 | appsettings.json + Program.cs 注册 | 2 文件 | 15min |
| K6 | 字典补充 + 编译验证 | 管理端 | 15min |
| K7 | 联调验证 (需 KMS 环境) | — | 2h |
| K8 | Phase 2: 远程授权 + 前端按钮 | 3 文件 | 3h |
---
> **版本历史**:
> - v1.0 (2025-05-17) — 初版,未完整覆盖所有接口
> - v2.0 (2025-05-19) — 完整覆盖 50+ REST 接口,明确 Phase 1/2 分界,修正技术栈为 .NET 8

View File

@@ -1,424 +0,0 @@
# SecMPS 整合方案 v3.0
> 版本: 3.0
> 日期: 2025-05-17
> 状态: 草稿
---
## 1. 背景与目标
SecMPS 需要将多套异构子系统视频监控、IoT动环、门禁道闸、报警统一整合到 Vol.Pro 管理端。v2.0 采用 Vol.Pro 直接对接各子系统 API存在紧耦合、协议适配散落、扩展困难等问题。
v3.0 引入 **IntegrationGateway整合网关** 作为统一中间层,面向子系统做协议适配,面向 Vol.Pro 提供标准化 REST API实现"适配一次,多处复用"。
### 1.1 核心原则
| 原则 | 说明 |
|------|------|
| 适配器隔离 | 每个子系统一个 Adapter实现统一接口互不干扰 |
| 懒加载 | 适配器按需初始化,不阻塞网关启动 |
| 分页语义统一 | 统一 page/size 分页,适配器内部转换 skip/limit |
| 字段分治 | 网关负责"来源标识+在线状态"Vol.Pro 负责"设备名称+扩展属性",首次入库全量,后续仅更新网关字段 |
### 1.2 子系统清单
| 子系统 | 对接方式 | 适配器 | 核心能力 |
|--------|----------|--------|----------|
| Owl + ZLMediaKit | REST API | OwlAdapter | 视频设备发现、实时流、PTZ、录像回放、截图 |
| MC4.0 | REST API | MC4Adapter | 对象树、实时点值、设备控制、告警查询/确认/结束 |
---
## 2. 架构总览
```
┌──────────────────────────────────────────────────────────┐
│ Vol.Pro 管理端 │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ gateway_nodes│ │ base_device │ │ device_manager │ │
│ │ Controller │ │ Controller │ │ (前端页面) │ │
│ │ A1-A4 │ │ GetRegion..│ │ RegionTree + │ │
│ │ │ │ GetDevices │ │ DeviceTable │ │
│ └──────┬───────┘ └──────┬───────┘ └────────┬────────┘ │
│ │ │ │ │
│ │ GatewayClient │ │ HTTP │
│ └────────┬────────┘ │ │
│ │ B3 (REST) │ │
└──────────────────┼─────────────────────────────┼──────────┘
│ │
┌──────────────────┼─────────────────────────────┼──────────┐
│ IntegrationGateway (net8.0) │ │
│ │ │ │
│ ┌───────────────┴──────────────────────────────┴───────┐ │
│ │ AdapterRegistry │ │
│ │ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ OwlAdapter │ │ MC4Adapter │ ... │ │
│ │ │ IHasFlat.. │ │ IHasOwnTree │ │ │
│ │ │ IHasStreams │ │ IHasPoints │ │ │
│ │ │ IHasPtz │ │ IHasAlarms │ │ │
│ │ └──────┬──────┘ └──────┬──────┘ │ │
│ └─────────┼────────────────┼───────────────────────────┘ │
│ │ │ │
│ ┌────────┴────────┐ ┌───┴──────────┐ │
│ │ Owl + ZLMediaKit│ │ MC4.0 │ │
│ │ (视频监控) │ │ (动环/IoT) │ │
│ └─────────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────────────┘
```
### 2.1 数据流
```
MC4.0 对象树 → Mc4Adapter.GetObjectTree()
→ SyncEngine.ProcessMc4Tree()
→ type=1 节点 → warehouse_regions (区域匹配)
→ type=2 节点 → base_device (Upsert, 字段分治)
```
### 2.2 网关端口约定
| 服务 | 端口 | 说明 |
|------|------|------|
| IntegrationGateway | 5100 | 设备管理 REST API |
| Owl + ZLMediaKit | 15123 | 视频流/录像 |
| MC4.0 | 3000 | 动环数据/告警 |
| Vol.Pro | 9991 | 管理端后端 |
---
## 3. 核心接口体系
### 3.1 能力接口Capability Interfaces
```csharp
// 适配器按需实现,网关通过反射或接口判断自动发现能力
interface IHasFlatDevices { Task<PagedResult<StandardDevice>> GetDevicesAsync(int page, int size, string? keyword); }
interface IHasOwnDeviceTree { Task<List<DeviceTreeNode>> GetObjectTreeAsync(); }
interface IHasStreams { Task<StreamUrls> GetLiveUrlAsync(string channelId); Task<StreamUrls> GetPlaybackUrlAsync(string channelId, DateTime start, DateTime end); }
interface IHasPtz { Task PtzControlAsync(string channelId, string direction, float speed); }
interface IHasRecordings { Task<PagedResult<StandardRecording>> GetRecordingsAsync(string channelId, DateTime start, DateTime end, int page, int size); }
interface IHasPoints { Task<List<PointValue>> GetRealtimeValuesAsync(string sourceDeviceId); Task SetPointValueAsync(string sourceDeviceId, int pointIndex, double value); }
interface IHasAlarms { Task<PagedResult<StandardAlarm>> GetAlarmsAsync(int page, int size, DateTime from, DateTime to, ...); Task ConfirmAlarmAsync(string alarmId); Task EndAlarmAsync(string alarmId); }
interface IAcceptsMetadataPush { Task<MetadataPushResult> PushMetadataAsync(string sourceDeviceId, MetadataChangeSet changes); }
```
### 3.2 网关统一模型
```csharp
class StandardDevice {
int DeviceId; // Vol.Pro 侧主键(同步后回填)
string AdapterCode; // "Owl:main" / "MC4:31ku"
string SourceId; // 子系统原始 ID
string DeviceName;
string DeviceCategory; // 摄像机/温湿度变送器/...
string DeviceGroup; // 视频设备/IoT设备/门禁设备/...
bool IsParent;
string ParentSourceId;
bool IsOnline;
string IpAddress;
int? Port;
Dictionary<string,object?> Extra; // 子系统扩展属性
}
class StandardAlarm {
string AlarmId;
string DeviceId;
string AdapterCode;
string Level; // 提示/普通/重要/紧急
string Title;
string Content;
DateTime OccurTime;
string Status; // 未确认/已确认/已结束
double? ActualValue;
double? ThresholdValue;
}
class DeviceTreeNode {
string SourceId;
string Name;
int NodeType; // 1=区域, 2=设备
int ObjectType;
string Tag;
Dictionary<string,object?> Option;
List<DeviceTreeNode> Children;
}
```
### 3.3 订阅接口B3供 Vol.Pro 回调)
| 路由 | 方法 | 说明 |
|------|------|------|
| `/api/gateway/register` | POST | 网关注册 (Upsert) |
| `/api/gateway/heartbeat` | POST | 心跳 |
| `/api/gateway/sync/devices` | POST | 设备数据同步 |
| `/api/gateway/sync/alarms` | POST | 告警同步 |
### 3.4 网关暴露接口
| 路由 | 适配器能力 | 说明 |
|------|-----------|------|
| `GET /devices?adapter={code}&page=&size=` | IHasFlatDevices | 分页获取设备列表 |
| `GET /tree?adapter={code}` | IHasOwnDeviceTree | 获取对象树 |
| `GET /streams/{adapter}/{deviceId}/live` | IHasStreams | 获取实时流地址 |
| `GET /streams/{adapter}/{deviceId}/playback` | IHasStreams | 获取回放地址 |
| `POST /streams/{adapter}/{deviceId}/snapshot` | IHasStreams | 获取截图 |
| `POST /streams/{adapter}/{deviceId}/ptz` | IHasPtz | 云台控制 |
| `GET /recordings/{adapter}/{deviceId}` | IHasRecordings | 获取录像列表 |
| `GET /points/{adapter}/{deviceId}` | IHasPoints | 获取实时点值 |
| `POST /points/{adapter}/{deviceId}` | IHasPoints | 控制写值 |
| `GET /alarms/{adapter}` | IHasAlarms | 分页获取告警 |
| `POST /alarms/{adapter}/{id}/confirm` | IHasAlarms | 确认告警 |
| `POST /alarms/{adapter}/{id}/end` | IHasAlarms | 结束告警 |
| `GET /health` | - | 适配器健康状态 |
---
## 4. 数据库设计
### 4.1 新增表5 张Vol.Pro 侧)
```sql
-- 网关节点表
CREATE TABLE gateway_nodes (
NodeId INT IDENTITY PRIMARY KEY,
NodeCode NVARCHAR(50) NOT NULL,
NodeName NVARCHAR(100),
NodeToken NVARCHAR(200),
AdapterTypes NVARCHAR(200), -- "Owl:main,MC4:31ku"
BaseUrl NVARCHAR(500),
IsOnline NVARCHAR(10), -- 在线/离线
Enable NVARCHAR(10), -- 启用/禁用
LastHeartbeat DATETIME,
CreateDate DATETIME,
CreateID INT,
Creator NVARCHAR(50),
ModifyDate DATETIME,
ModifyID INT,
Modifier NVARCHAR(50)
);
-- 统一设备表
CREATE TABLE base_device (
DeviceId INT IDENTITY,
AdapterCode NVARCHAR(50) NOT NULL, -- 联合主键
DeviceName NVARCHAR(100),
SourceId NVARCHAR(100) NOT NULL, -- 联合主键 (AdapterCode + SourceId = 唯一)
DeviceCategory NVARCHAR(50), -- 摄像机/硬盘录像机/温湿度变送器/...
DeviceGroup NVARCHAR(50), -- 视频设备/IoT设备/门禁设备/...
PointId INT NULL, -- FK → warehouse_devicepoint
GatewayNodeId INT NULL, -- FK → gateway_nodes
IsParent NVARCHAR(10),
ParentDeviceId INT NULL,
IsOnline NVARCHAR(10),
Enable NVARCHAR(10),
Protocol NVARCHAR(50),
IpAddress NVARCHAR(50),
Port INT NULL,
LastSyncTime DATETIME,
MapModelId NVARCHAR(100),
MapModelScale NVARCHAR(50),
MapModelRotation NVARCHAR(200),
ExtraData NVARCHAR(MAX), -- JSON 扩展属性
CreateDate DATETIME,
CreateID INT,
Creator NVARCHAR(50),
ModifyDate DATETIME,
ModifyID INT,
Modifier NVARCHAR(50),
PRIMARY KEY (DeviceId, AdapterCode)
);
-- 数据归档表
CREATE TABLE iot_devicedata (
DataId INT IDENTITY PRIMARY KEY,
DeviceId INT NULL,
AdapterCode NVARCHAR(50),
PointIndex INT,
Value FLOAT,
UpdateTime DATETIME,
CreateDate DATETIME
);
-- 告警记录表
CREATE TABLE iot_alarm (
AlarmId INT IDENTITY PRIMARY KEY,
SourceAlarmId NVARCHAR(100),
DeviceId INT NULL,
AdapterCode NVARCHAR(50),
AlarmLevel NVARCHAR(20),
AlarmDesc NVARCHAR(500),
AlarmValue FLOAT,
ThresholdValue FLOAT,
StartTime DATETIME,
EndTime DATETIME,
ConfirmTime DATETIME,
State NVARCHAR(20), -- 未确认/已确认/已结束
ConfirmUser NVARCHAR(50),
CreateDate DATETIME
);
-- 视频通道表base_device 子表)
CREATE TABLE video_channel (
ChannelId INT NOT NULL,
DeviceId INT NOT NULL, -- FK → base_device
ChannelNo NVARCHAR(50),
SourceId NVARCHAR(100),
ChannelName NVARCHAR(100),
IsOnline NVARCHAR(10),
CreateDate DATETIME,
PRIMARY KEY (ChannelId, DeviceId)
);
-- 录像文件表
CREATE TABLE video_record (
RecordId INT IDENTITY PRIMARY KEY,
ChannelId INT NULL,
FilePath NVARCHAR(500),
Size BIGINT,
StartTime DATETIME,
EndTime DATETIME,
Duration FLOAT,
CreateDate DATETIME
);
```
### 4.2 字典初始化
```sql
-- 设备分类字典
INSERT INTO Sys_Dictionary (DicName, DicValue, DicNo, Config) VALUES
('设备分组', '视频设备', 'device_group_1', '视频设备'),
('设备分组', 'IoT设备', 'device_group_2', 'IoT设备'),
('设备分组', '门禁设备', 'device_group_3', '门禁设备'),
('设备分组', '道闸设备', 'device_group_4', '道闸设备'),
('设备分组', '报警设备', 'device_group_5', '报警设备');
```
---
## 5. 管理端前端
### 5.1 页面结构
```
/web.vite/src/views/warehouse/DeviceManager/
├── index.vue 左右分栏主页面
├── api/
│ └── deviceManager.js API 封装
└── components/
├── RegionTree.vue 区域→点位树 (左侧)
├── DeviceTable.vue 设备列表 (右侧)
├── VideoDeviceActions.vue 视频设备操作按钮组
├── DeviceLivePreview.vue 实时预览弹窗
├── PtzControlPanel.vue 云台方向键面板
├── MapBindingPanel.vue 地图绑定面板
└── DeviceEditDialog.vue 设备编辑弹窗
```
### 5.2 路由
```
/device-manager → DeviceManager/index.vue
```
### 5.3 功能矩阵
| 功能 | 触发条件 | 组件 | 说明 |
|------|---------|------|------|
| 区域树展开 | 页面加载 | RegionTree | 调 GetRegionTree API |
| 设备列表 | 点击点位 | DeviceTable | 调 GetDevicesByPoint API |
| 实时预览 | 视频设备→预览 | DeviceLivePreview | WS-FLV 播放 |
| 云台控制 | 视频设备→云台 | PtzControlPanel | ↑↓←→ + ZOOM |
| 查看回放 | 视频设备→回放 | (待实现) | 录像时间轴 |
| 获取快照 | 视频设备→快照 | (待实现) | JPEG 快照 |
| 地图绑定 | 任意设备→地图 | MapBindingPanel | 模型 ID/缩放/旋转 |
| 编辑设备 | 非视频设备→编辑 | DeviceEditDialog | 名称/种类/分组/启用 |
---
## 6. 实施计划
### Phase 0: 基础设施Day 1-2
| Day | 内容 | 产出 |
|-----|------|------|
| 1 | 网关项目骨架 (net8.0) | IntegrationGateway.sln, Core/Host 项目, 7 个能力接口, 10 个统一模型, 3 个基础设施 (Registry/RateLimiter/HttpClientFactory) |
| 2 | Vol.Pro 侧集成 | GatewayClient, gateway_nodesController(A1-A4骨架), base_deviceController(骨架), Quartz Job, db_init.sql, 代码生成器产物 |
### Phase 1: Owl 适配器 + 管理端Day 3-5
| Day | 内容 | 产出 |
|-----|------|------|
| 3 | OwlAdapter | RSA 加密登录, 3 个接口实现 (IHasFlatDevices + IHasStreams + IAcceptsMetadataPush) |
| 4 | DeviceManager 页面框架 | RegionTree + DeviceTable + router |
| 5 | 视频组件 | 预览/云台/地图绑定/编辑弹窗 |
### Phase 2: MC4 适配器 + 联调Day 6-11
| Day | 内容 | 产出 |
|-----|------|------|
| 6 | 联调验证 (Owl + MC4) | 需子系统就绪 |
| 7 | MC4Adapter | Token 认证, IHasOwnDeviceTree + IHasPoints + IHasAlarms |
| 8 | 区域自动匹配 | SyncEngine, 字段分治, parentSourceId 映射 |
| 9 | 物联网操作接口 | 实时值读取, 控制写值 API |
| 10 | 告警集成 + SignalR | 告警查询/确认/结束, SignalR 实时推送 |
| 11 | 地图绑定 + Quartz | MapBindingPanel, SyncDevicesJob, HeartbeatMonitorJob |
### Phase 3: Warehouse 端Day 12-17
| Day | 内容 | 产出 |
|-----|------|------|
| 12 | 钥匙柜管理 | 钥匙柜设备 + 钥匙借还流程 |
| 13 | 巡更管理 | 巡更路径/点位/排班 |
| 14 | 门禁管理 | 门禁一体机 + 授权 |
| 15 | 道闸管理 | 人行道闸 + 车辆道闸 |
| 16 | 报警管理 | 紧急报警 + 离线报警 |
| 17 | 仓库页面整合 | 菜单 + 权限 |
### Phase 4: 验证发布Day 18-20
| Day | 内容 | 产出 |
|-----|------|------|
| 18 | 系统测试 | 功能测试 + 边界测试 |
| 19 | 性能优化 | 流性能 + 并发 + 缓存 |
| 20 | 文档 + 发布 | 部署手册, Dockerfile |
---
## 7. 风险与约束
| 风险 | 缓解措施 |
|------|----------|
| Vol.Pro 框架 API 与自主代码不兼容 | 网关层独立编译dotnet buildVol.Pro 后端逻辑骨架化,联调时一边调一边补 |
| Owl 接口不稳定 | 统一 2 QPS 限流 + 重试 |
| MC4 字段映射复杂 | 对象树 Option 弹性 JSON 字段,扩容不破坏现有映射 |
| 设备数量大导致同步慢 | 增量同步 + 批量 Upsert |
---
## 8. 环境配置
### 8.1 网关 appsettings.json
```json
{
"Owl": {
"BaseUrl": "http://owl_host:15123",
"Username": "admin",
"Password": "your_owl_password"
},
"MC4": {
"BaseUrl": "http://mc4_host:3000"
}
}
```
### 8.2 启动顺序
1. Owl + ZLMediaKit → 端口 15123
2. MC4.0 → 端口 3000
3. IntegrationGateway → 端口 5100 (`dotnet run --project src/IntegrationGateway.Host`)
4. Vol.Pro 后端 → 端口 9991
5. web.vite 前端 → 端口 9000 (`npm run dev`)

View File

@@ -0,0 +1,364 @@
# KMS 钥匙柜适配器详细设计文档
> **版本**: 1.0
> **日期**: 2025-05-19
> **基准**: `doc/整合方案/KMS钥匙柜整合方案_v2.0.md`
> **接口文档**: `doc/对接文档/钥匙管理系统软件接口.docx` (KMS API v1.0.4)
> **技术栈**: .NET 8 / ASP.NET Core / C#
---
## 1. 接口覆盖设计
### 1.1 完整 KMS API 概览
KMS 系统共有 **38 个 REST 端点**,分 9 大类。按设计原则,适配器只代理 **第三方集成接口**(第 2.18 节),不代理 KMS 自身管理接口。
### 1.2 第三方集成接口Phase 1 — 核心实现)
以下 8 个接口是 KMS 专为第三方对接设计的扁平化 API
| # | 方法 | 路径 | 用途 | 适配器方法 |
|---|:---:|------|------|------|
| 2.18.1 | GET | `/prod-api/heartBeat` | 心跳检测 | `HealthCheckAsync()` |
| 2.18.2 | POST | `/prod-api/batchDeleteStaff` | 批量删除员工 | `BatchDeleteStaffAsync()` |
| 2.18.3 | POST | `/prod-api/batchSyncStaff` | 批量同步员工 | `BatchSyncStaffAsync()` |
| 2.18.4 | POST | `/prod-api/getOpenerList` | 查询柜体+钥匙信息 | `GetDevicesAsync()` |
| 2.18.5 | POST | `/prod-api/getPermissionList` | 查询授权记录 | `GetPermissionListAsync()` |
| 2.18.6 | POST | `/prod-api/getRecordList` | 查询借还记录 | `GetBorrowRecordsAsync()` |
| 2.18.7 | POST | `/prod-api/getWarningList` | 查询告警记录 | `GetAlarmsAsync()` |
| 2.18.8 | POST | `/thirdPlatlogin` | 第三方登录/事件 | `ThirdPlatLoginAsync()` |
加上认证接口 `POST /prod-api/getToken`,适配器共需对接 **9 个 KMS 接口**
### 1.3 标准 KMS 管理接口Phase 2 可选)
以下 29 个接口属于 KMS 自身管理功能(员工 CRUD、柜体 CRUD、钥匙 CRUD 等KMS 自带 Web 管理端即可操作。Vol.Pro 如需代理这些接口,可在 Phase 2 扩展,但设计文档不列入必需实现。
| 模块 | 接口数 | 备注 |
|------|:---:|------|
| 交接记录 | 2 | GET `/kms/handover/*` |
| 授权管理 | 3 | GET `/kms/permission/*` + POST remote |
| 告警记录(标准) | 1 | GET `/kms/warning/list` |
| 员工可借钥匙 | 2 | POST/GET `/kms/staffopener/*` |
| 员工管理 | 5 | CRUD `/kms/staff/*` |
| 员工组管理 | 6 | CRUD `/kms/staffGroup/*` |
| 物品类别 | 6 | CRUD `/kms/openerType/*` |
| 柜体管理 | 5 | CRUD `/kms/locker/*` + statistics |
| 锁孔管理 | 4 | CRUD `/kms/lockhole/*` |
| 钥匙管理 | 7 | CRUD `/kms/opener/*` + selectCanBorrow |
| 钥匙组 | 7 | CRUD `/kms/openerGroup/*` |
| 部门 | 1 | GET `/system/dept/root/{userId}` |
| 授权详情 | 1 | GET `/kms/permissioninfo/getByPermissionId/{uuid}` |
| **合计** | **29** | Phase 2 按需实现 |
---
## 2. 项目结构
```
gateway/src/IntegrationGateway.Adapters.Kms/
├── IntegrationGateway.Adapters.Kms.csproj # 类库, net8.0
├── KmsAdapter.cs # 适配器主体 (IHasFlatDevices + IHasAlarms)
├── KmsAuthHelper.cs # Bearer Token 认证
└── KmsModels.cs # 请求/响应 DTO
```
### 2.1 依赖关系
```
Host → Adapters.Kms → Core
Host → Core
```
适配器只引用 Core零外部 NuGet 依赖。
---
## 3. 数据模型设计
### 3.1 KMS 响应模型(全部 9 个第三方接口的 DTO
```csharp
// ── 认证 ──
public class KmsTokenResponse { public int Code { get; set; } public string Token { get; set; } = ""; public string? Msg { get; set; } }
// ── 2.18.4 柜体+钥匙 ──
public class KmsOpenerListResponse { public int Code { get; set; } public string? Msg { get; set; } public List<KmsLocker>? Rows { get; set; } }
public class KmsLocker { public int LockerId { get; set; } public string? LockerName { get; set; } public string? LockerCode { get; set; } public List<KmsLockhole>? LockholeList { get; set; } }
public class KmsLockhole { public int LockholeSort { get; set; } public int OpenerId { get; set; } public string? OpenerName { get; set; } public string? OpenerType { get; set; } public string? OpenerState { get; set; } }
// ── 2.18.7 告警 ──
public class KmsWarningListResponse { public int Code { get; set; } public string? Msg { get; set; } public int Total { get; set; } public List<KmsWarning>? Rows { get; set; } }
public class KmsWarning { public string? Uuid { get; set; } public string? LockerName { get; set; } public int LockholeSort { get; set; } public string? OpenerName { get; set; } public int Type { get; set; } public string? WarningTime { get; set; } public string? Remark { get; set; } public string? StaffName { get; set; } }
// ── 2.18.6 借还记录 ──
public class KmsRecordListResponse { public int Code { get; set; } public string? Msg { get; set; } public int Total { get; set; } public List<KmsRecord>? Rows { get; set; } }
public class KmsRecord { public string? Uuid { get; set; } public string? LockerName { get; set; } public int LockholeSort { get; set; } public string? OpenerName { get; set; } public string? StaffName { get; set; } public string? BorrowTime { get; set; } public string? ReturnTime { get; set; } public string? Type { get; set; } }
// ── 2.18.5 授权记录 ──
public class KmsPermissionListResponse { public int Code { get; set; } public string? Msg { get; set; } public int Total { get; set; } public List<KmsPermission>? Rows { get; set; } }
public class KmsPermission { public string? Uuid { get; set; } public string? LockerName { get; set; } public int LockholeSort { get; set; } public string? OpenerName { get; set; } public string? StaffName { get; set; } public string? ApplyTime { get; set; } public string? BackTime { get; set; } }
// ── 2.18.3 员工同步 ──
public class KmsStaff { public string? Uuid { get; set; } public string? Name { get; set; } public string? CardNo { get; set; } public string? Phone { get; set; } public string? Email { get; set; } public int? DeptId { get; set; } public int? GroupId { get; set; } public int State { get; set; } public int Type { get; set; } }
// ── 通用响应 ──
public class KmsApiResponse { public int Code { get; set; } public string? Msg { get; set; } }
```
### 3.2 实体映射关系
```
KMS 物理拓扑 base_device 映射
─────── ──────────────────
智能钥匙柜A (lockerId=25) SourceId="locker_25", IsParent=是
├── 锁孔1 "仓库大门" SourceId="lockhole_25_1", ParentSourceId="locker_25"
├── 锁孔2 "机房钥匙" SourceId="lockhole_25_2", ParentSourceId="locker_25"
└── 锁孔N ...
```
---
## 4. KmsAuthHelper 设计
```csharp
/// <summary>
/// KMS Bearer Token 认证辅助。
/// Token 通过 POST /prod-api/getToken 获取,参数 clientId + clientSecret。
/// 缓存 25 分钟KMS 有效期 30 分钟,留 5 分钟余量)。
/// </summary>
public class KmsAuthHelper
{
private readonly HttpClient _http;
private readonly string _baseUrl, _clientId, _clientSecret;
private string? _token;
private DateTime _tokenExpiry = DateTime.MinValue;
public KmsAuthHelper(HttpClient http, string baseUrl, string clientId, string clientSecret)
{
_http = http; _baseUrl = baseUrl.TrimEnd('/');
_clientId = clientId; _clientSecret = clientSecret;
}
/// <summary>获取或刷新 Token自动缓存</summary>
public async Task<string> GetTokenAsync()
{
if (!string.IsNullOrEmpty(_token) && DateTime.UtcNow < _tokenExpiry) return _token;
var resp = await _http.PostAsync(
$"{_baseUrl}/prod-api/getToken?clientId={Uri.EscapeDataString(_clientId)}&clientSecret={Uri.EscapeDataString(_clientSecret)}", null);
resp.EnsureSuccessStatusCode();
var result = await resp.Content.ReadFromJsonAsync<KmsTokenResponse>()
?? throw new Exception("KMS Token 响应为空");
if (result.Code != 200) throw new Exception($"KMS 认证失败: code={result.Code}");
_token = result.Token; _tokenExpiry = DateTime.UtcNow.AddMinutes(25);
return _token;
}
/// <summary>创建一个已认证的 HttpClient</summary>
public async Task<HttpClient> GetAuthenticatedClientAsync()
{
var token = await GetTokenAsync();
var client = new HttpClient { BaseAddress = new Uri(_baseUrl) };
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");
return client;
}
public void Invalidate() => _token = null;
}
```
---
## 5. KmsAdapter 设计
### 5.1 类声明与核心属性
```csharp
/// <summary>
/// KMS 智能钥匙柜适配器。
/// 实现 IHasFlatDevices + IHasAlarms。
/// 通过 8 个第三方接口2.18.X对接 KMS 子系统。
/// AdapterCode: "KMS:{InstanceName}",限流: 5 QPS。
/// </summary>
public class KmsAdapter : IHasFlatDevices, IHasAlarms
{
private readonly HttpClient _http;
private readonly KmsAuthHelper _auth;
private readonly RateLimiter _limiter = new(5);
public string AdapterCode { get; }
public string DisplayName => $"KMS ({AdapterCode})";
public AdapterCapabilities Capabilities => new() { HasFlatDevices = true, HasAlarms = true };
public KmsAdapter(string adapterCode, HttpClient http, string baseUrl, string clientId, string clientSecret)
{
AdapterCode = adapterCode; _http = http;
_auth = new KmsAuthHelper(http, baseUrl, clientId, clientSecret);
}
public async Task InitializeAsync() => await _auth.GetTokenAsync();
```
### 5.2 IGatewayAdapter — 健康检查
```csharp
/// <summary>2.18.1: 心跳检测 — GET /prod-api/heartBeat</summary>
public async Task<bool> HealthCheckAsync()
{
try
{
var client = await _auth.GetAuthenticatedClientAsync();
var resp = await client.GetAsync("/prod-api/heartBeat");
return resp.IsSuccessStatusCode;
}
catch { return false; }
}
```
### 5.3 IHasFlatDevices — 设备同步
```csharp
/// <summary>2.18.4: 柜体+钥匙信息 — POST /prod-api/getOpenerList</summary>
public async Task<PagedResult<StandardDevice>> GetDevicesAsync(int page, int size, string? keyword = null)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
var resp = await client.PostAsync("/prod-api/getOpenerList",
new StringContent("{}", Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
var data = await resp.Content.ReadFromJsonAsync<KmsOpenerListResponse>()!;
var devices = new List<StandardDevice>();
foreach (var locker in data.Rows ?? new())
{
// 父设备: 柜体
devices.Add(new StandardDevice
{
SourceId = $"locker_{locker.LockerId}", Name = locker.LockerName ?? $"柜体{locker.LockerId}",
Category = "智能钥匙柜", Group = "门禁设备", IsParent = true, IsOnline = true,
Extra = new() { ["lockerCode"] = locker.LockerCode, ["lockholeCount"] = locker.LockholeList?.Count ?? 0 }
});
// 子设备: 锁孔(钥匙位)
foreach (var hole in locker.LockholeList ?? new())
{
devices.Add(new StandardDevice
{
SourceId = $"lockhole_{locker.LockerId}_{hole.LockholeSort}",
Name = hole.OpenerName ?? $"锁孔{hole.LockholeSort}",
Category = "钥匙位", Group = "门禁设备", IsParent = false,
IsOnline = hole.OpenerState == "在位", ParentSourceId = $"locker_{locker.LockerId}",
Extra = new() { ["openerId"] = hole.OpenerId, ["openerType"] = hole.OpenerType, ["openerState"] = hole.OpenerState }
});
}
}
return new PagedResult<StandardDevice> { Items = devices, Total = devices.Count };
}
```
### 5.4 IHasAlarms — 告警同步
```csharp
/// <summary>2.18.7: 告警记录 — POST /prod-api/getWarningList</summary>
public async Task<PagedResult<StandardAlarm>> GetAlarmsAsync(...)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
var resp = await client.PostAsync("/prod-api/getWarningList",
new StringContent("{}", Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
var data = await resp.Content.ReadFromJsonAsync<KmsWarningListResponse>()!;
var alarms = (data.Rows ?? new()).Select(w => new StandardAlarm
{
AlarmId = w.Uuid ?? "", AdapterCode = AdapterCode, Level = "普通",
Title = $"{w.LockerName} 锁孔{w.LockholeSort}: {w.OpenerName}",
Content = w.Remark, OccurTime = DateTime.TryParse(w.WarningTime, out var t) ? t : DateTime.MinValue,
Status = w.Type == 1 ? "未确认" : "已结束"
}).ToList();
return new PagedResult<StandardAlarm> { Items = alarms, Total = data.Total };
}
```
### 5.5 扩展方法(非接口方法,供 B 组路由直接调用)
```csharp
// 2.18.6 借还记录
public async Task<PagedResult<KmsRecord>> GetBorrowRecordsAsync(DateTime? from = null, DateTime? to = null) { ... }
// 2.18.5 授权记录
public async Task<PagedResult<KmsPermission>> GetPermissionListAsync(DateTime? from = null, DateTime? to = null) { ... }
// 2.18.3 批量同步员工
public async Task BatchSyncStaffAsync(List<KmsStaff> staffList) { ... }
// 2.18.2 批量删除员工
public async Task BatchDeleteStaffAsync(List<string> staffUuids) { ... }
// 2.4.3 远程授权开门
public async Task RemoteAuthorizeAsync(KmsRemotePermissionRequest request) { ... }
// 2.18.8 第三方登录代理
public async Task<string?> ThirdPlatLoginAsync(string username) { ... }
```
---
## 6. 配置
### 6.1 KmsConfig POCO
```csharp
public class KmsConfig
{
public string? InstanceName { get; set; }
public string BaseUrl { get; set; } = "";
public string ClientId { get; set; } = "";
public string ClientSecret { get; set; } = "";
}
```
### 6.2 appsettings.json
```json
{
"KMS": [
{
"InstanceName": "main",
"BaseUrl": "http://192.168.1.50:8080",
"ClientId": "your_client_id",
"ClientSecret": "your_client_secret"
}
]
}
```
### 6.3 Program.cs 注册
```csharp
var kmsList = app.Configuration.GetSection("KMS").Get<List<KmsConfig>>() ?? new();
foreach (var k in kmsList)
{
var code = $"KMS:{k.InstanceName ?? "default"}";
var a = new KmsAdapter(code,
app.Services.GetRequiredService<IHttpClientFactory>().CreateClient("VolPro"),
k.BaseUrl, k.ClientId, k.ClientSecret);
registry.Register(a);
}
```
---
## 7. Vol.Pro 端配套
| 项 | 改动 | 说明 |
|------|:---:|------|
| 数据库 | 无 | base_device / iot_alarm 已兼容 |
| 后端 | 无 | A1-A4 同步逻辑通用 |
| 字典 | 新增 2 项 | "智能钥匙柜" / "钥匙位" |
| 前端列表 | 无 | 自动显示 KMS 设备 |
| 前端操作 | Phase 2 | KeyDeviceActions.vue开门/授权按钮)|
---
> **接口覆盖**: 9 个第三方接口2.18.X + Token100% 设计覆盖29 个标准管理接口留 Phase 2 按需扩展。

View File

@@ -0,0 +1,183 @@
# KMS 钥匙柜适配器 — 任务清单
> **基准文档**: `doc/设计文档/KMS钥匙柜适配器详细设计文档.md`
> **分支**: gateway-dev
> **原则**: 严格按照设计文档执行,不凭空添加。网关/Vol.Pro 改动放倒数第二步,联调放最后。
---
## Phase K0: 项目骨架(预计 15min
### K0.1 创建适配器项目
- [ ]`gateway/src/` 下执行 `dotnet new classlib -n IntegrationGateway.Adapters.Kms -f net8.0`
- [ ] 删除自动生成的 `Class1.cs`
- [ ] 添加项目引用:`dotnet add reference ../IntegrationGateway.Core/IntegrationGateway.Core.csproj`
- [ ] 将项目加入解决方案:`dotnet sln add`
### K0.2 Host 引用适配器
- [ ] `dotnet add Host reference Adapters.Kms`
### K0.3 编译验证
- [ ] `dotnet build` → 0 错误
> **K0 提交点**: `PhaseK0_scaffold — Kms适配器项目骨架就绪`
---
## Phase K1: KmsModels — 数据模型(预计 1h
### K1.1 认证模型
- [ ] 创建 `KmsModels.cs`
- [ ] `KmsTokenResponse { Code, Token, Msg }`
### K1.2 第三方接口响应模型2.18.X
- [ ] `KmsOpenerListResponse { Code, Msg, Rows }`
- [ ] `KmsLocker { LockerId, LockerName, LockerCode, LockholeList }`
- [ ] `KmsLockhole { LockholeSort, OpenerId, OpenerName, OpenerType, OpenerState }`
- [ ] `KmsWarningListResponse { Code, Msg, Total, Rows }`
- [ ] `KmsWarning { Uuid, LockerName, LockholeSort, OpenerName, Type, WarningTime, Remark, StaffName }`
- [ ] `KmsRecordListResponse { Code, Msg, Total, Rows }`
- [ ] `KmsRecord { Uuid, LockerName, LockholeSort, OpenerName, StaffName, BorrowTime, ReturnTime, Type }`
### K1.3 编译验证
- [ ] `dotnet build` → 0 错误
> **K1 提交点**: `PhaseK1_models — KmsModels.cs 完整定义全部响应 DTO`
---
## Phase K2: KmsAuthHelper — 认证(预计 30min
### K2.1 创建 KmsAuthHelper.cs
- [ ] 构造函数:接收 `HttpClient`, `baseUrl`, `clientId`, `clientSecret`
- [ ] 属性:`_token` (string?), `_tokenExpiry` (DateTime)
### K2.2 GetTokenAsync
- [ ] POST `/prod-api/getToken?clientId=xx&clientSecret=yy`
- [ ] 校验 `Code == 200`
- [ ] 缓存 Token过期时间 = `UtcNow.AddMinutes(25)`30 分钟效期5 分钟余量)
### K2.3 GetAuthenticatedClientAsync
- [ ] 创建 `HttpClient`,设置 `Authorization: Bearer {token}`
- [ ] Invalidate() → `_token = null`
### K2.4 编译验证
- [ ] `dotnet build` → 0 错误
> **K2 提交点**: `PhaseK2_auth — Bearer Token 认证就绪`
---
## Phase K3: KmsAdapter 核心方法(预计 1.5h
### K3.1 类定义
- [ ] `public class KmsAdapter : IHasFlatDevices, IHasAlarms`
- [ ] 属性:`AdapterCode`, `DisplayName`, `Capabilities`
### K3.2 HealthCheckAsync2.18.1
- [ ] GET `/prod-api/heartBeat`
- [ ] 异常捕获返回 false + Console.Error 打日志
### K3.3 GetDevicesAsync2.18.4
- [ ] POST `/prod-api/getOpenerList` (body `{}`)
- [ ] 遍历柜体/锁孔 → 映射为 StandardDevice
- [ ] 父设备 `IsParent=是`, 子设备 `ParentSourceId=locker_{id}`
### K3.4 GetAlarmsAsync2.18.7
- [ ] POST `/prod-api/getWarningList`
- [ ] 映射 KmsWarning → StandardAlarm
- [ ] AlarmId=uuid, Status=Type==1?"未确认":"已结束"
### K3.5 ConfirmAlarmAsync / EndAlarmAsync
- [ ] Confirm 调标准接口End 留空实现
### K3.6 编译验证
- [ ] `dotnet build` → 0 错误
> **K3 提交点**: `PhaseK3_adapter_core — 核心4方法就绪`
---
## Phase K4: 扩展方法(预计 1h
### K4.1 借还/授权/员工/登录
- [ ] GetBorrowRecordsAsync2.18.6
- [ ] GetPermissionListAsync2.18.5
- [ ] BatchSyncStaffAsync2.18.3
- [ ] BatchDeleteStaffAsync2.18.2
- [ ] RemoteAuthorizeAsync2.4.3
- [ ] ThirdPlatLoginAsync2.18.8
### K4.2 编译验证
- [ ] `dotnet build` → 0 错误
> **K4 提交点**: `PhaseK4_adapter_ext — 6个扩展方法就绪`
---
## Phase K5: 配置与注册(预计 15min
### K5.1 KmsConfig POCO
- [ ] 在 Program.cs 同级加 class属性`InstanceName, BaseUrl, ClientId, ClientSecret`
### K5.2 appsettings.json
- [ ] 新增 KMS 数组配置段
### K5.3 Program.cs 注册
- [ ] `var kmsList = app.Configuration.GetSection("KMS").Get<List<KmsConfig>>() ?? new();`
- [ ] foreach 注册 `KmsAdapter("KMS:{InstanceName}", ...)`
> **K5 提交点**: `PhaseK5_config — 配置+注册就绪`
---
## Phase K6: 编译与自测(预计 15min
### K6.1 编译验证
- [ ] `dotnet build` → 0 错误
> **K6 提交点**: `PhaseK6_build — 全量编译通过`
---
## Phase K7: Vol.Pro 端配套(预计 1h
### K7.1 字典
- [ ] 管理端设备种类字典 ← "智能钥匙柜" + "钥匙位"
### K7.2 前端按钮
- [ ] `base_device.vue` 操作列:门禁设备 → [开门] [授权] 按钮
> **K7 提交点**: `PhaseK7_volpro — 字典+前端就绪`
---
## Phase K8: 联调验证(预计 3h需 KMS 环境)
### K8.1 认证
- [ ] 网关启动 → KmsAdapter.InitializeAsync 成功
### K8.2 设备/告警/记录
- [ ] /api/gateway/devices?adapter=KMS:main → 返回柜体+锁孔
- [ ] /api/gateway/alarms/KMS:main → 返回告警列表
- [ ] /api/gateway/control/KMS:main → 远程开门
### K9: 联调文档记录
- [ ] 记录异常接口到 KMS_联调笔记.txt
> **K8 提交点**: `PhaseK8_integration — 全链路联调通过`
---
| Phase | 内容 | 文件 | 预计 |
|:---:|------|:---:|:---:|
| K0 | 项目骨架 | 2 | 15min |
| K1 | 全部 DTO | 1 | 1h |
| K2 | AuthHelper | 1 | 30min |
| K3 | 核心方法 | 1 | 1.5h |
| K4 | 扩展方法 | 1 | 1h |
| K5 | 配置注册 | 3 | 15min |
| K6 | 编译 | — | 15min |
| K7 | VolPro配套 | 2 | 1h |
| K8 | 联调 | — | 3h |
| **合计** | — | **11** | **~9h** |

View File

@@ -0,0 +1,19 @@
Phase V3.6: Quartz Job 注册说明
═══════════════════════════════
以下 3 个 Job 需在 Vol.Pro 管理端 → Quartz 任务管理 中手动创建:
1. SyncDevicesJob
- 类名: VolPro.Warehouse.Services.SyncDevicesJob
- Cron: 0 */5 * * * ? (每5分钟)
- 说明: 遍历在线网关触发全量设备同步
2. HeartbeatMonitorJob
- 类名: VolPro.Warehouse.Services.HeartbeatMonitorJob
- Cron: 0/15 * * * * ? (每15秒)
- 说明: 检测心跳超时标记离线
3. RealtimePollJob (Phase 2 启用)
- 类名: VolPro.Warehouse.Services.RealtimePollJob
- Cron: 0/10 * * * * ? (每10秒)
- 说明: 轮询MC4.0实时值写iot_devicedata

View File

@@ -0,0 +1,56 @@
# SecMPS 统一问题清单 2026-06-03
> **版本**: 1.0
> **日期**: 2026-06-03
> **范围**: gateway / VolPro (api_sqlsugar) / web.vite / warehouse / owl_zlmediakit
> **来源**: 项目深度审计 + 规则引擎方案审查
---
## P0 — 阻塞性(影响功能完整性,必须修复)
| 编号 | 类别 | 问题 | 影响 | 方案 |
|:---:|:---:|------|------|------|
| P0-1 | 规则引擎 | RealtimePollJob 空壳 — IoT 实时值从未持久化,规则引擎无历史数据源 | 规则无法追溯历史趋势 | 在此 Job 实现轮询→写入 iot_devicedata或合并到 RuleEngineJob |
| P0-2 | 网关 | A1 自注册未调用 — `GatewayClientFactory.RegisterAsync` 已定义但 Program.cs 从未执行 | 网关启动后不向 Vol.Pro 注册 | `InitializeAllAsync()` 后遍历适配器调 A1 |
| P0-3 | 安全 | B 组路由零认证 — 14+ 条路由无任何认证 | 内网未授权客户端可操控设备、查视频流 | 生产环境绑定 `127.0.0.1`,或加 `X-Gateway-Key` 中间件 |
---
## P1 — 重要(影响性能、安全、可靠性)
| 编号 | 类别 | 问题 | 影响 | 方案 |
|:---:|:---:|------|------|------|
| P1-1 | 性能 | 逐设备 B4 调用 — 规则引擎按设备逐个调 B4 | 规则引擎 90% 时间耗在网络往返 | 新增 `POST /realtime/{adapter}/batch` 批量接口 |
| P1-2 | 性能 | 级联离线标记逐条 UPDATE — HeartbeatMonitorJob 对每台设备单独更新 | 设备多时慢且无事务 | 一条 SQL: `UPDATE base_device SET IsOnline='离线' WHERE GatewayNodeId=@id` |
| P1-3 | 安全 | Token/密码明文存储 — appsettings.json 明文且被复制到 bin/ | 源码泄露 = 凭据泄露 | 环境变量覆盖 + `.gitignore bin` |
| P1-4 | 可维护 | 前端硬编码网关地址 — `const GW = 'http://localhost:5100'` | 部署时需逐文件修改 | 统一用 `window.apiConfig.gatewayUrl` |
| P1-5 | 规则引擎 | DeviceId→(AdapterCode, SourceId) 解析缺失 | 规则引擎无法直接调网关 B4 | 批量查 base_device 建映射表 |
| P1-6 | 规则引擎 | ValueId 语义模糊 — 字典绑定但无对应实体表 | "变量"选的是什么不明确 | 新建 `warehouse_variable` 表 |
---
## P2 — 改善(影响排错效率、维护成本)
| 编号 | 类别 | 问题 | 影响 | 方案 |
|:---:|:---:|------|------|------|
| P2-1 | 代码质量 | 静默异常吞噬 — 适配器 `catch { return false; }` | 离线不知道原因 | `catch(Exception ex)` + STDERR 输出 |
| P2-2 | 规则引擎 | 阈值抖动 — 温度反复跳变时规则频繁触发→恢复 | 空调反复开关,告警洪水 | hysteresis 滞后窗 |
| P2-3 | 规则引擎 | 冷却期粒度 — Cooldown 在规则级OR 组合不该整体冷却 | 冷却期过宽 | 冷却期下沉到条件表或基于"上次触发值"去重 |
| P2-4 | 可维护 | warehouse 端 console.log 残留 — 30+ 处开发日志 | 生产环境噪声 | vite.config.ts 移除非生产日志 |
| P2-5 | 可维护 | 双端 gateway API 重复封装 | 维护两份 | 统一到 web.vite/src/api/gateway.js |
---
## P3 — 优化(影响开发体验、仓库整洁)
| 编号 | 类别 | 问题 | 影响 | 方案 |
|:---:|:---:|------|------|------|
| P3-1 | 规则引擎 | 动作执行阻塞 — `ExecuteActionsAsync` 串行等待 B5 响应 | 一条规则卡住全部阻塞 | `Task.WhenAll` + 5s 超时 |
| P3-2 | 文档 | bin 目录残留配置 | 仓库体积 + 凭据泄露 | `.gitignore``**/bin/` |
| P3-3 | 开发 | 网关无 Swagger | 调试需手动 curl | `AddEndpointsApiExplorer` + `MapSwagger` |
| P3-4 | 文档 | 设计文档与代码路由数不一致 | 架构文档过时 | 每次 Phase 同步更新 |
---
> **总计**: 18 项 — P0: 3 / P1: 6 / P2: 5 / P3: 4

View File

@@ -0,0 +1,280 @@
# SecMPS 统一问题清单 2026-06-03 修复方案
> **版本**: 1.0
> **日期**: 2026-06-03
> **基准**: `SecMPS统一问题清单20260603.md`
> **原则**: 按优先级逐项修复,每项修复后编译验证;涉及网关/Vol.Pro 改动的放一组批量提交
---
## 修复总览
| 阶段 | 优先级 | 涉及项目 | 文件数 | 预计 |
|:---:|:---:|------|:---:|:---:|
| F1 | P0-1 ~ P0-3 | gateway + Vol.Pro | 5 | 2h |
| F2 | P1-1 ~ P1-6 | gateway + Vol.Pro + 库表 + 前端 | 8 | 4h |
| F3 | P2-1 ~ P2-5 | gateway + warehouse | 8 | 2h |
| F4 | P3-1 ~ P3-4 | gateway + 文档 | 4 | 1h |
| **合计** | — | 4 项目 | **25** | **~9h** |
---
## 阶段 F1: P0 阻塞项修复(预计 2h
#### F1.1 [P0-1] RealtimePollJob 填充实现
- [ ] 编辑 `api_sqlsugar/Warehouse/Services/RealtimePollJob.cs`
- [ ] 注入 `GatewayClient` + `Ibase_deviceRepository`
- [ ] `Execute()` 中:
1. 查询在线 MC4 网关 (`gateway_nodes WHERE IsOnline=在线 AND AdapterTypes LIKE '%MC4%'`)
2. 查对应设备列表 (`base_device WHERE DeviceGroup='IoT设备' AND IsOnline=在线`)
3. 对每个设备调 `GatewayClient.GetRealtimeAsync(gwBaseUrl, adapterCode, sourceId)`
4. 结果写入 `iot_devicedata`INSERT 新记录)
- [ ] `dotnet build` → 0 错误
#### F1.2 [P0-2] 网关 A1 自注册
- [ ] 编辑 `gateway/src/IntegrationGateway.Host/Program.cs`
- [ ]`registry.InitializeAllAsync()` 后加入:
```csharp
var nodeCode = gwCfg["NodeCode"] ?? "gw-default";
var nodeToken = gwCfg["NodeToken"] ?? "";
var adapterTypes = string.Join(",", registry.All.Select(a => a.AdapterCode));
await clientFactory.RegisterAsync(nodeCode, nodeToken, adapterTypes, volProUrl);
```
- [ ] `dotnet build` → 0 错误
#### F1.3 [P0-3] B 组路由认证
- [ ] 编辑 `gateway/src/IntegrationGateway.Host/Program.cs`
- [ ] 在 `app.UseCors()` 之后添加中间件:
```csharp
var gatewayKey = gwCfg["GatewayKey"];
if (!string.IsNullOrEmpty(gatewayKey))
{
app.Use(async (context, next) => {
var key = context.Request.Headers["X-Gateway-Key"].FirstOrDefault();
if (key == gatewayKey || context.Request.Path == "/") { await next(); }
else { context.Response.StatusCode = 401; }
});
}
```
- [ ] appsettings.json Gateway 段新增 `"GatewayKey": null`
- [ ] Vol.Pro 端 `GatewayClient` 所有 HTTP 请求头自动附加 `X-Gateway-Key`
- [ ] `dotnet build` → 0 错误
> **F1 提交点**: `Fix-P0: RealtimePollJob+A1自注册+B组认证`
---
## 阶段 F2: P1 重要项修复(预计 4h
#### F2.1 [P1-1] 网关新增批量实时值接口
- [ ] 编辑 `gateway/src/IntegrationGateway.Host/Program.cs`
- [ ] 新增 B4-batch 路由:
```csharp
app.MapPost("/api/gateway/realtime/{adapter}/batch", async (string adapter, BatchRealtimeRequest req) =>
{
var a = registry.FindByCode<IHasPoints>(adapter);
if (a == null) return Results.NotFound();
var results = new Dictionary<string, List<PointValue>>();
foreach (var deviceId in req.DeviceIds ?? new())
results[deviceId] = await a.GetRealtimeValuesAsync(deviceId);
return Results.Ok(results);
});
```
- [ ] 新增 `record BatchRealtimeRequest(List<string>? DeviceIds);`
- [ ] `dotnet build` → 0 错误
#### F2.2 [P1-2] 批量级联离线标记
- [ ] 编辑 `api_sqlsugar/Warehouse/Services/HeartbeatMonitorJob.cs`
- [ ] 替换逐条 `UpdateAsync` 为:
```csharp
context.Repository.DbContext.Db.Ado.ExecuteCommand(
"UPDATE base_device SET IsOnline='离线' WHERE GatewayNodeId=@id AND IsOnline='在线'",
new { id = node.GatewayNodeId });
```
- [ ] `dotnet build` → 0 错误
#### F2.3 [P1-3] 凭据安全化
- [ ] 编辑 `gateway/src/IntegrationGateway.Host/appsettings.json`
- `NodeToken` → `null`, 加注释 "生产环境由 SECMPS_GATEWAY_TOKEN 环境变量注入"
- Owl `Password` → `""`, 加注释
- KMS `ClientSecret` → `""`, 加注释
- [ ] 编辑 `gateway/src/IntegrationGateway.Host/Program.cs`
- `gwCfg["NodeToken"]` → `Environment.GetEnvironmentVariable("SECMPS_GATEWAY_TOKEN") ?? gwCfg["NodeToken"]`
- [ ] 编辑 `.gitignore` → 加 `**/bin/`、`**/obj/`
- [ ] `git rm -r --cached gateway/src/IntegrationGateway.Host/bin/`
#### F2.4 [P1-4] 前端网关地址统一化
- [ ] 编辑 `web.vite/public/index.html` 的 `window.apiConfig` → 加 `gatewayUrl: 'http://localhost:5100'`
- [ ] 编辑 `web.vite/src/views/warehouse/device_manager/base_device.vue`
- `const GW = 'http://localhost:5100'` → `const GW = window.apiConfig.gatewayUrl || 'http://localhost:5100'`
- [ ] 编辑 `warehouse/src/api/gateway.ts`
- `const GW_BASE = 'http://localhost:5100'` → 读取 `window.apiConfig.gatewayUrl`
- [ ] 编辑 `warehouse/index.html` 的 `window.apiConfig` → 加 `gatewayUrl`
#### F2.5 [P1-5] 规则引擎增加 DeviceId 映射
- [ ] 在规则引擎实现方案中增加 `BuildDeviceMappingAsync` 方法:
```csharp
var deviceIds = rules.SelectMany(r => r.Conditions).Select(c => c.DeviceId).Distinct();
var devices = await _deviceRepo.FindAsync(d => deviceIds.Contains(d.DeviceId));
var map = devices.ToDictionary(d => d.DeviceId, d => (d.AdapterCode, d.SourceId));
```
- [ ] 后续调网关时用 `map[cond.DeviceId]` 拼装 URL
#### F2.6 [P1-6] 新建 warehouse_variable 表
- [ ] 执行 SQL:
```sql
CREATE TABLE warehouse_variable (
VariableId INT IDENTITY PRIMARY KEY,
DeviceId INT NOT NULL,
VariableName NVARCHAR(255), -- 温度/湿度/人数
PointIndex INT DEFAULT 0, -- MC4 pointIndex
Unit NVARCHAR(50), -- ℃/%/人
SortOrder INT DEFAULT 0
);
```
- [ ] 在 Vol.Pro 代码生成器选择 `warehouse_variable`,生成全套 CRUD 代码
- [ ] 管理端字典 "变量列表" 绑定到 `warehouse_variable.VariableName`
- [ ] 规则条件/动作的 `ValueId` 下拉框改为从 `warehouse_variable` 查询JOIN `base_device.DeviceId`
> **F2 提交点**: `Fix-P1: B4-batch+批量离线+凭据安全+前端地址+DeviceId映射+变量表`
---
## 阶段 F3: P2 改善项修复(预计 2h
#### F3.1 [P2-1] 适配器异常日志
- [ ] 编辑 `gateway/src/IntegrationGateway.Adapters.Owl/OwlAdapter.cs`
- [ ] 编辑 `gateway/src/IntegrationGateway.Adapters.MC4/Mc4Adapter.cs`
- [ ] 编辑 `gateway/src/IntegrationGateway.Adapters.Kms/KmsAdapter.cs`
- [ ] 所有 `catch { return false; }` → `catch (Exception ex) { Console.Error.WriteLine($"[{AdapterCode}] HealthCheck: {ex.Message}"); return false; }`
- [ ] `dotnet build` → 0 错误
#### F3.2 [P2-2] 规则引擎滞后窗
- [ ] 在 `warehouse_rulecondition` 表新增字段:
```sql
ALTER TABLE warehouse_rulecondition ADD
RecoveryThreshold_Numeric DECIMAL(18,2) NULL, -- 恢复阈值(下界)
RecoveryThreshold_Switch NVARCHAR(50) NULL; -- 恢复开关状态
```
- [ ] `RuleEngineService.EvaluateCondition` 中加逻辑:
```csharp
bool wasTriggered = cond.LastTriggered.HasValue;
if (wasTriggered)
return Compare(actualValue, "大于等于", cond.RecoveryThreshold_Numeric);
else
return Compare(actualValue, cond.CompareOperator, cond.TargetValue_Number);
```
#### F3.3 [P2-3] 条件级冷却
- [ ] `warehouse_rulecondition` 表新增 `LastTriggered DATETIME NULL`、`LastTriggerValue DECIMAL(18,2) NULL`
- [ ] `RuleEngineService.EvaluateCondition` 中:
- 如果 `DateTime.Now - cond.LastTriggered < rule.CooldownSec` → 跳过此条件
- 触发时更新 `LastTriggered` 和 `LastTriggerValue`
#### F3.4 [P2-4] 生产环境移除 console.log
- [ ] 编辑 `warehouse/vite.config.ts`
```typescript
build: {
terserOptions: { compress: { drop_console: true } }
}
```
- [ ] 开发环境保留 `console.log`(仅 build 时移除)
- [ ] `npm run build` → 确认无 console.log 残留
#### F3.5 [P2-5] 统一 gateway API 封装
- [ ] 复制 `warehouse/src/api/gateway.ts` → `web.vite/src/api/gateway.js`
- 修改 `GW_BASE` 为 `window.apiConfig.gatewayUrl || 'http://localhost:5100'`
- [ ] `web.vite/src/views/warehouse/device_manager/base_device.vue`
- 删除内联 `const GW =` + `fetch()` → 改为 `import { gwGet, gwPost } from '@/api/gateway.js'`
- 所有 `fetch(\`\${GW}/api/gateway/...\`)` → `gwGet(...)` / `gwPost(...)`
> **F3 提交点**: `Fix-P2: 异常日志+滞后窗+条件冷却+console清理+API统一`
---
## 阶段 F4: P3 优化项(预计 1h
#### F4.1 [P3-1] 规则引擎并发动作执行
- [ ] 在规则引擎实现方案的 `ExecuteActionsAsync` 中:
```csharp
var tasks = actions.Select(a => ExecuteSingleActionAsync(a, rule));
await Task.WhenAll(tasks);
async Task ExecuteSingleActionAsync(Action a, Rule r) {
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try { await DoAction(a, r, cts.Token); }
catch (OperationCanceledException) { Log($"[RuleEngine] 动作超时: {a.id}"); }
}
```
#### F4.2 [P3-2] 清理 bin/obj + .gitignore
- [ ] `.gitignore` 追加规则(如未在 F2.3 中完成):
```
**/bin/
**/obj/
gateway/src/IntegrationGateway.Host/bin/
api_sqlsugar/**/bin/
```
- [ ] `git rm -r --cached` 所有 bin/obj 目录
#### F4.3 [P3-3] 网关 Swagger
- [ ] 编辑 `gateway/src/IntegrationGateway.Host/Program.cs`:
```csharp
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// ...
app.UseSwagger();
app.UseSwaggerUI();
```
- [ ] 浏览器访问 `http://localhost:5100/swagger` 验证
#### F4.4 [P3-4] 同步设计文档路由数
- [ ] 编辑 `doc/设计文档/对接网关设计文档.md` → 路由表从 14 条更新为当前实际数
- [ ] 确认以下设计文档一致: 对接网关设计文档、规则引擎方案、KMS 设计文档
> **F4 提交点**: `Fix-P3: 并发动作+清理bin+Swagger+文档同步`
---
## 任务总览
| 编号 | 问题 | 涉及文件 | 预计 |
|:---:|------|------|:---:|
| P0-1 | RealtimePollJob 空壳 | RealtimePollJob.cs | 1h |
| P0-2 | A1 自注册 | Program.cs | 30min |
| P0-3 | B 组认证 | Program.cs + appsettings | 30min |
| P1-1 | B4-batch | Program.cs | 30min |
| P1-2 | 批量离线 | HeartbeatMonitorJob.cs | 20min |
| P1-3 | 凭据安全 | appsettings + .gitignore + bin | 20min |
| P1-4 | 前端地址 | base_device.vue + gateway.ts | 20min |
| P1-5 | DeviceId 映射 | RuleEngineService | 30min |
| P1-6 | 变量表 | SQL + 代码生成 + 前端 | 1h |
| P2-1 | 异常日志 | OwlAdapter + MC4Adapter + KmsAdapter | 20min |
| P2-2 | 滞后窗 | SQL + RuleEngineService | 30min |
| P2-3 | 条件冷却 | SQL + RuleEngineService | 20min |
| P2-4 | console 清理 | vite.config.ts | 10min |
| P2-5 | API 统一 | gateway.js + base_device.vue | 30min |
| P3-1 | 并发动作 | RuleEngineService | 15min |
| P3-2 | bin 清理 | .gitignore + git rm | 5min |
| P3-3 | Swagger | Program.cs | 10min |
| P3-4 | 文档同步 | 设计文档 | 15min |
> **总计**: 18 项 / 25 文件 / ~9h

View File

@@ -0,0 +1,424 @@
# VolPro.WebApi 网关相关接口文档
> **版本**: 1.0
> **日期**: 2026-06-04
> **基址**: `http://{host}:{port}`(默认 `http://localhost:9100`
> **内容类型**: `application/json`
> **接口来源**: `api_sqlsugar/VolPro.WebApi/Controllers/Warehouse/`
---
## 目录
1. [A 组 — 网关注册通信](#1-a-组--网关注册通信)
- A1: 网关注册 `POST /api/gateway/register`
- A2: 心跳上报 `POST /api/gateway/heartbeat`
- A3: 设备同步 `POST /api/gateway/sync/devices`
- A4: 告警同步 `POST /api/gateway/sync/alarms`
2. [设备管理](#2-设备管理)
- 区域树 `GET /api/DeviceManager/GetRegionTree`
- 点位设备 `GET /api/DeviceManager/GetDevicesByPoint`
3. [定时任务](#3-定时任务)
- 设备同步任务 `POST /api/task/syncDevices`
- 心跳监控任务 `POST /api/task/heartbeatMonitor`
- 实时轮询任务 `POST /api/task/realtimePoll`
- 规则引擎任务 `POST /api/task/ruleEngine`
4. [错误代码](#4-错误代码)
---
## 1. A 组 — 网关注册通信
> A 组接口是 VolPro 向网关暴露的管理端点,由网关主动调用。所有 A 组使用 `[AllowAnonymous]` + `NodeToken` 二次认证,不走 VolPro JWT 体系。
>
> **实现文件**: `Controllers/Warehouse/Partial/gateway_nodesController.cs`
### A1: 网关注册Upsert
```
POST /api/gateway/register
```
网关启动时调用注册自身节点信息。NodeCode 已存在则更新,不存在则插入。返回当前网关的已有设备列表供网关对比差异。
**请求头**: `Content-Type: application/json`
**请求体 (GatewayRegisterRequest)**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `NodeCode` | string | ✅ | 网关节点编码,如 `gw-31ku` |
| `Token` | string | ✅ | 认证令牌(由环境变量 `SECMPS_GATEWAY_TOKEN` 注入) |
| `AdapterTypes` | string | ✅ | 适配器类型列表(逗号分隔),如 `Owl:main,MC4:31ku` |
| `BaseUrl` | string | ✅ | 网关自身地址,如 `http://192.168.1.10:5100` |
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `nodeId` | int | 网关节点 IDbase_device.NodeId 外键) |
| `devices` | array | 该网关已有的设备列表 |
**devices[] 条目**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `deviceId` | int | 设备自增 ID |
| `deviceName` | string | 设备名称 |
| `adapterCode` | string | 适配器编码 |
| `sourceId` | string | 子系统设备原始 ID |
| `deviceCategory` | string | 设备种类 |
| `deviceGroup` | string | 设备分组 |
| `isParent` | string | 是否父设备("是"/"否" |
| `isOnline` | string | 是否在线("在线"/"离线" |
| `extraData` | string? | 扩展数据 JSON |
**返回示例**:
```json
{
"nodeId": 1,
"devices": [
{ "deviceId": 10, "deviceName": "NVR-1", "adapterCode": "Owl:main", "sourceId": "nvr_001", "deviceCategory": "硬盘录像机", "deviceGroup": "视频设备", "isParent": "是", "isOnline": "在线" }
]
}
```
**错误响应**:
| HTTP | 说明 |
|:---:|------|
| 400 | `NodeCode``Token` 为空 |
| 401 | `NodeToken` 不匹配(已有节点 Token 变更) |
---
### A2: 心跳上报
```
POST /api/gateway/heartbeat
```
网关每 15s 调用一次,更新 `LastHeartbeat` 字段。连续失败 ≥3 次45s后网关自动触发 A1+A3 重注册。
**请求体 (GatewayHeartbeatRequest)**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `NodeCode` | string | ✅ | 网关节点编码 |
| `Token` | string | ✅ | 认证令牌 |
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `status` | string | 固定 `"ok"` |
| `serverTime` | string | 服务器时间 (`yyyy-MM-dd HH:mm:ss`) |
**错误响应**:
| HTTP | 说明 |
|:---:|------|
| 400 | `NodeCode``Token` 为空 |
| 401 | NodeCode+Token 组合不匹配 |
---
### A3: 设备数据同步
```
POST /api/gateway/sync/devices
```
网关每次设备变更后调用,将全量设备列表推送到 VolPro。采用字段分治策略首次入库写全量后续只更新网关字段。
**请求体 (SyncDevicesRequest)**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `NodeCode` | string | ✅ | 网关节点编码 |
| `Token` | string | ✅ | 认证令牌 |
| `Devices` | array | ✅ | 设备列表 |
**Devices[].SyncDeviceItemDto**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `AdapterCode` | string | ✅ | 适配器编码 |
| `SourceId` | string | ✅ | 子系统设备原始 ID |
| `Name` | string? | ❌ | 设备名称 |
| `Category` | string? | ❌ | 设备种类 |
| `Group` | string? | ❌ | 设备分组 |
| `IsParent` | bool | ❌ | 是否父设备 |
| `ParentSourceId` | string? | ❌ | 父设备 SourceId用于解析 ParentDeviceId |
| `IsOnline` | bool | ❌ | 是否在线 |
| `IpAddress` | string? | ❌ | IP 地址 |
| `Port` | int? | ❌ | 端口号 |
| `ExtraDataJson` | string? | ❌ | 扩展数据 JSON |
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `added` | int | 新增设备数 |
| `updated` | int | 更新设备数 |
| `removed` | int | 固定 `0`(当前版本不移除下线设备) |
**错误响应**:
| HTTP | 说明 |
|:---:|------|
| 400 | `NodeCode``Token` 为空 |
| 401 | NodeCode+Token 认证失败 |
---
### A4: 告警数据同步
```
POST /api/gateway/sync/alarms
```
网关检测到新告警后调用,推送告警列表到 VolPro。通过 `SourceAlarmId` 去重(同一告警不重复入库)。
**请求体 (SyncAlarmsRequest)**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `NodeCode` | string | ✅ | 网关节点编码 |
| `Token` | string | ✅ | 认证令牌 |
| `Alarms` | array | ✅ | 告警列表 |
**Alarms[].SyncAlarmItemDto**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `SourceAlarmId` | string | ✅ | 子系统告警唯一 ID用于去重 |
| `DeviceSourceId` | string | ✅ | 关联设备 SourceId用于映射 DeviceId |
| `AdapterCode` | string | ✅ | 适配器编码 |
| `Level` | string | ✅ | 告警等级:`提示`/`普通`/`重要`/`紧急` |
| `Desc` | string | ✅ | 告警描述 |
| `Value` | double? | ❌ | 告警实际值 |
| `StartTime` | string | ✅ | 告警发生时间 |
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `added` | int | 新增告警数 |
**错误响应**:
| HTTP | 说明 |
|:---:|------|
| 400 | `NodeCode``Token` 为空 |
| 401 | NodeCode+Token 认证失败 |
---
## 2. 设备管理
> **实现文件**: `Controllers/Warehouse/Partial/base_deviceController.cs`
### 区域树
```
GET /api/DeviceManager/GetRegionTree
```
返回 区域→点位 的层级结构,供管理端左侧树形控件使用。
**请求参数**: 无
**返回参数**: `TreeNode[]`
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | string | 节点 ID`r_{regionId}``p_{pointId}` |
| `label` | string | 节点显示名称 |
| `type` | string | 节点类型:`region`(区域) 或 `point`(点位) |
| `deviceCount` | int | 该节点下的设备数量 |
| `children` | array? | 子节点列表(仅 region 节点有) |
**返回示例**:
```json
[
{
"id": "r_1", "label": "库房A区", "type": "region", "deviceCount": 3,
"children": [
{ "id": "p_10", "label": "温湿度监测点1", "type": "point", "deviceCount": 5 },
{ "id": "p_11", "label": "门禁点1", "type": "point", "deviceCount": 2 }
]
}
]
```
---
### 点位设备列表
```
GET /api/DeviceManager/GetDevicesByPoint?pointId={pointId}&page={page}&size={size}
```
获取指定点位下的设备列表(含子设备),支持分页。
**请求参数**:
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|:--:|------|------|
| `pointId` | int | ✅ | — | 点位 ID |
| `page` | int | ❌ | 1 | 页码 |
| `size` | int | ❌ | 20 | 每页条数 |
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `items` | array | 设备列表 |
| `total` | int | 总设备数 |
**items[] 条目**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `deviceId` | int | 设备自增 ID |
| `deviceName` | string | 设备名称 |
| `adapterCode` | string | 适配器编码 |
| `sourceId` | string | 子系统设备原始 ID |
| `deviceCategory` | string | 设备种类 |
| `deviceGroup` | string | 设备分组(`视频设备`/`IoT设备`/`门禁设备` |
| `isParent` | string | 是否父设备("是"/"否" |
| `parentDeviceId` | int? | 父设备 ID |
| `isOnline` | string | 是否在线("在线"/"离线" |
| `ipAddress` | string? | IP 地址 |
| `port` | int? | 端口号 |
| `location` | string? | 位置描述 |
| `extraData` | string? | 扩展数据 JSON |
| `lastSyncTime` | DateTime? | 最后同步时间 |
| `mapModelId` | string? | 3D 地图模型 ID |
| `mapModelScale` | decimal? | 模型缩放比例 |
| `mapModelRotation` | string? | 模型旋转参数 JSON |
| `enable` | string | 启用状态("启用"/"停用" |
---
## 3. 定时任务
> VolPro 框架通过 `Sys_QuartzOptions` 表配置 URL+Cron 定时调用。每个端点加 `[ApiTask]` 属性以允许框架匿名调用。
>
> **实现文件**: `Controllers/Warehouse/TaskController.cs`
### 设备同步任务
```
POST /api/task/syncDevices
```
遍历所有在线网关,触发全量设备同步至 VolPro。
**Cron**: `0 */5 * * * ?`(每 5 分钟)
**请求参数**: 无
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `time` | DateTime | 执行时间 |
| `status` | string | 固定 `"ok"` |
**错误响应**:
| HTTP | 说明 |
|:---:|------|
| 500 | `gateway_nodesService` 未注册 |
---
### 心跳监控任务
```
POST /api/task/heartbeatMonitor
```
扫描心跳超时 ≥30s 的网关节点,标记离线并级联标记该节点下所有设备离线。
**Cron**: `0/15 * * * * ?`(每 15 秒)
**请求参数**: 无
**返回**: 同设备同步任务
---
### 实时轮询任务
```
POST /api/task/realtimePoll
```
轮询所有在线 MC4 IoT 设备的实时值,写入 `iot_devicedata` 表。
**Cron**: `0/10 * * * * ?`(每 10 秒)
**请求参数**: 无
**返回**: 同设备同步任务
---
### 规则引擎任务
```
POST /api/task/ruleEngine
```
加载启用规则 → 从网关批量获取实时值 → 逐规则评估条件 → 触发动作(控制/告警/通知)。
**Cron**: `0/10 * * * * ?`(每 10 秒)
**请求参数**: 无
**返回**: 同设备同步任务
**当前状态**: 桩实现。`RuleEngineService.EvaluateAllAsync()` 抛出 `NotImplementedException`,需先执行 SQL ALTER TABLE + 代码生成器。
---
## 4. 错误代码
### HTTP 状态码
| 状态码 | 含义 | 触发条件 |
|:---:|------|------|
| 200 | OK | 请求成功 |
| 400 | Bad Request | 必填参数缺失(`NodeCode`/`Token`/`pointId` |
| 401 | Unauthorized | A 组接口 NodeToken 认证失败 |
| 500 | Internal Server Error | 服务未注册或内部异常 |
### A 组认证错误
所有 A 组接口在认证失败时返回:
```json
{ "message": "认证失败Token 无效" }
```
```json
{ "message": "认证失败" }
```
### 定时任务错误
定时任务在服务未注册时返回:
```json
{ "error": "服务未注册: gateway_nodesService" }
```
---
> **接口总数**: 10 个端点A 组 4 + 设备管理 2 + 定时任务 4
> **实现位置**: `api_sqlsugar/VolPro.WebApi/Controllers/Warehouse/`

View File

@@ -0,0 +1,174 @@
# 定时任务 API 化整改方案 v1.0
> **版本**: 1.0
> **日期**: 2026-06-04
> **背景**: VolPro 框架的 Quartz 机制基于 `[ApiTask]` + URL 调用,不支持 `IJob` 接口
> **现状**: 4 个 IJob 实现SyncDevices/HeartbeatMonitor/RealtimePoll/RuleEngineJob需迁移为 API 端点
---
## 1. 影响范围
| 任务 | 当前文件 | 需改为 | 调度间隔 |
|------|------|------|:---:|
| 设备同步 | `SyncDevicesJob.cs` (IJob) | Controller + `[ApiTask]` | 每5分钟 |
| 心跳监控 | `HeartbeatMonitorJob.cs` (IJob) | Controller + `[ApiTask]` | 每15秒 |
| 实时轮询 | `RealtimePollJob.cs` (IJob) | Controller + `[ApiTask]` | 每10秒 |
| 规则引擎 | `RuleEngineJob.cs` (IJob) | Controller + `[ApiTask]` | 每10秒 |
---
## 2. 整改步骤
### 步骤 T1: 创建任务调度 Controller预计 30min
**新建文件**: `api_sqlsugar/VolPro.WebApi/Controllers/Warehouse/TaskController.cs`
```csharp
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using VolPro.Core.Filters;
using Warehouse.Services;
namespace Warehouse.Controllers;
/// <summary>
/// 定时任务 API 端点。
/// VolPro 框架通过 Sys_QuartzOptions 配置 URL+Cron 定时调用。
/// 每个方法加 [ApiTask] 属性以允许框架匿名调用。
/// </summary>
[ApiController]
[Route("api/task")]
public class TaskController : Controller
{
/// <summary>T1: 设备同步 — 遍历在线网关触发全量设备同步</summary>
[ApiTask]
[HttpGet, HttpPost, Route("syncDevices")]
public async Task<IActionResult> SyncDevices()
{
var sp = HttpContext.RequestServices;
var engine = sp.GetService<SyncDevicesJob>();
if (engine != null) await engine.Execute(null!);
return Ok(new { time = DateTime.Now, status = "ok" });
}
/// <summary>T2: 心跳监控 — 扫描超时网关标记离线</summary>
[ApiTask]
[HttpGet, HttpPost, Route("heartbeatMonitor")]
public async Task<IActionResult> HeartbeatMonitor()
{
var sp = HttpContext.RequestServices;
var engine = sp.GetService<HeartbeatMonitorJob>();
if (engine != null) await engine.Execute(null!);
return Ok(new { time = DateTime.Now, status = "ok" });
}
/// <summary>T3: 实时轮询 — 拉取 MC4 IoT 实时值</summary>
[ApiTask]
[HttpGet, HttpPost, Route("realtimePoll")]
public async Task<IActionResult> RealtimePoll()
{
var sp = HttpContext.RequestServices;
var engine = sp.GetService<RealtimePollJob>();
if (engine != null) await engine.Execute(null!);
return Ok(new { time = DateTime.Now, status = "ok" });
}
/// <summary>T4: 规则引擎 — 评估规则+执行动作</summary>
[ApiTask]
[HttpGet, HttpPost, Route("ruleEngine")]
public async Task<IActionResult> RuleEngine()
{
var sp = HttpContext.RequestServices;
var engine = sp.GetService<RuleEngineService>();
if (engine != null) await engine.EvaluateAllAsync();
return Ok(new { time = DateTime.Now, status = "ok" });
}
}
```
### 步骤 T2: 注册 DI预计 10min
**编辑文件**: `api_sqlsugar/VolPro.Core/Extensions/AutofacManager/AutofacContainerModuleExtension.cs`
或在 Warehouse 项目的 Startup/Module 中注册:
```csharp
// 在 Autofac 注册块中添加
builder.RegisterType<SyncDevicesJob>().AsSelf().InstancePerLifetimeScope();
builder.RegisterType<HeartbeatMonitorJob>().AsSelf().InstancePerLifetimeScope();
builder.RegisterType<RealtimePollJob>().AsSelf().InstancePerLifetimeScope();
builder.RegisterType<RuleEngineService>().AsSelf().InstancePerLifetimeScope();
```
如果已由 VolPro 框架自动扫描 Services 目录,则跳过此步骤。
### 步骤 T3: 管理端配置任务(预计 15min
在 Vol.Pro 管理端 → Quartz 管理 → 新建 4 个任务:
| TaskName | ApiUrl | Cron | Method |
|------|------|------|:--:|
| 设备同步 | `/api/task/syncDevices` | `0 */5 * * * ?` | POST |
| 心跳监控 | `/api/task/heartbeatMonitor` | `0/15 * * * * ?` | POST |
| 实时轮询 | `/api/task/realtimePoll` | `0/10 * * * * ?` | POST |
| 规则引擎 | `/api/task/ruleEngine` | `0/10 * * * * ?` | POST |
### 步骤 T4: 保留或删除 IJob 文件(预计 5min
**保留** IJob 实现类(`SyncDevicesJob.cs`不删除——Controller 通过 DI 获取它们并调用 `Execute()`
只需将 IJob 实现类用 `IServiceProvider` 获取(而非 Quartz 的 `JobDataMap`),因为 Controller 不传 `IJobExecutionContext`。修改 `Execute` 方法签名:
```csharp
// 旧: 依赖 IJobExecutionContext.JobDataMap
public async Task Execute(IJobExecutionContext context)
{
var sp = (IServiceProvider)context.JobDetail.JobDataMap["ServiceProvider"];
...
}
// 新: 注入 IServiceProvider 为构造函数参数
public class HeartbeatMonitorJob : IJob
{
private readonly IServiceProvider _sp;
public HeartbeatMonitorJob(IServiceProvider sp) { _sp = sp; }
public async Task Execute(IJobExecutionContext? context)
{
var gwSvc = _sp.GetService<Igateway_nodesService>();
var devRepo = _sp.GetService<Ibase_deviceRepository>();
...
}
}
```
### 步骤 T5: 编译验证(预计 10min
- [ ] `dotnet build api_sqlsugar/VolPro.WebApi` → 0 错误
- [ ] 确认 `[ApiTask]` 不与其他权限 Filter 冲突
---
## 3. 改动文件汇总
| 步骤 | 文件 | 改动 |
|:---:|------|------|
| T1 | `VolPro.WebApi/Controllers/Warehouse/TaskController.cs` | 新建4 个 `[ApiTask]` 端点 |
| T2 | DI 注册 | 可能不需改动VolPro 自动扫描) |
| T3 | 管理端 Sys_QuartzOptions | 新建 4 条任务记录 |
| T4 | 4 个 IJob 实现 | 构造函数改用 IServiceProvider 注入 |
| T5 | 全量编译 | 0 错误 |
---
## 4. 原 IJob 文件处理方案
| 文件 | 处理 |
|------|------|
| `SyncDevicesJob.cs` | 构造函数注入 IServiceProviderExecute 参数改为 nullable |
| `HeartbeatMonitorJob.cs` | 同上 |
| `RealtimePollJob.cs` | 同上 |
| `RuleEngineJob.cs` | 删除RuleEngineService 本身就是普通类,不继承 IJob |
> `RuleEngineJob.cs` 可直接删除——`RuleEngineService` 是普通类,已被 TaskController 直接调用。

View File

@@ -0,0 +1,218 @@
# warehouse 客户端改造 — 任务清单
> **基准文档**: 客户端改造方案 v1.0
> **分支**: gateway-dev
> **原则**: 改动仅在 warehouse/ 目录下,不影响 VolPro 管理端
---
## Phase W0: 基础设施(预计 0.5h
### W0.1 网关 API 封装
- [ ] 创建 `warehouse/src/api/gateway.ts`
- 实现 `gwGet(url)` — GET 请求网关
- 实现 `gwPost(url, body)` — POST 请求网关
- 网关基址常量 `GW_BASE = 'http://localhost:5100'`
### W0.2 设备数据模型
- [ ]`warehouse/src/api/gateway.ts` 中定义 TypeScript 接口:
- `Camera { id, name, location, status, adapterCode, streamUrl, hasPtz }`
- `StandardDevice { deviceId, adapterCode, sourceId, deviceName, deviceCategory, deviceGroup, isOnline, ... }`
- `StreamUrls { wsFlv, httpFlv, hls, ... }`
### W0.3 网关 CORS 确认
- [ ] 确认网关 `Program.cs` 中已启用 CORS`AllowAnyOrigin``AllowCredentials`
- [ ] 如未启用,在网关 `Program.cs` 中加 `builder.Services.AddCors()` + `app.UseCors()`
### W0.4 构建验证
- [ ] `npm run dev` 启动 warehouse确认无编译错误
> **W0 提交点**: `PhaseW0_infra — gateway.ts + 数据模型 + CORS 就绪`
---
## Phase W1: 实时视频页改造(预计 2h
### W1.1 获取真实摄像机列表
- [ ] 编辑 `warehouse/src/view/video/Live.vue`
- [ ] `onMounted` 中调用 `gwGet('/api/gateway/devices?adapter=Owl:main&page=1&size=100')`
- [ ]`StandardDevice[]` 映射到 `Camera[]`
- [ ] 替换硬编码的 5 台 Mock 摄像机
- [ ] 保留在线/离线统计计算逻辑
### W1.2 视频流播放
- [ ] 选中摄像机时调用 `gwGet('/api/gateway/streams/{adapterCode}/{sourceId}/live')`
- [ ] 获取 WS-FLV 地址后赋值给 `<video>` 标签的 `src`
- [ ] 替换当前占位 `<div class="video-placeholder">`
### W1.3 云台控制面板
- [ ] 在视频画面旁边增加云台方向键区域(↑↓←→ + ZOOM + 停止)
- [ ] 调用 `gwPost('/api/gateway/streams/{adapter}/{id}/ptz', { direction, action, speed })`
- [ ] mouseup/mouseleave 时发送 stop
### W1.4 构建验证
- [ ] 页面加载 → 显示真实 Owl 摄像机列表
- [ ] 点击摄像机 → 播放实时视频流
- [ ] 方向键 → Owl PTZ 响应
> **W1 提交点**: `PhaseW1_video_live — 真实摄像机列表 + WS-FLV播放 + 云台控制`
---
## Phase W2: 视频墙 + 录像回放(预计 2h
### W2.1 视频墙改造
- [ ] 编辑 `warehouse/src/view/video/VideoWall.vue`
- [ ] 页面加载时获取所有 Owl 摄像机列表
- [ ] 同时为前 N 路摄像机获取流地址
- [ ] 多路 `<video>` 标签并行播放(注意浏览器并发限制,建议 4 路)
### W2.2 录像回放改造
- [ ] 编辑 `warehouse/src/view/video/History.vue`
- [ ] 增加时间选择控件(开始时间 + 结束时间)
- [ ] 调用 `gwGet('/api/gateway/streams/{adapter}/{id}/playback?start=&end=')`
- [ ] 返回的 HLS 地址赋值给播放器
### W2.3 构建验证
- [ ] 视频墙同时显示 4 路画面
- [ ] 回放页时间轴选择 → HLS 播放
> **W2 提交点**: `PhaseW2_video_wall — 多路视频墙 + HLS回放`
---
## Phase W3: IoT 实时数据改造(预计 2h
### W3.1 IoT 设备列表
- [ ] 编辑 `warehouse/src/view/environment/EnvVarManagement.vue`
- [ ] 调用 `gwGet('/api/gateway/tree?adapter=MC4:31ku')` 获取对象树
- [ ] 递归遍历,筛选 `DeviceGroup=IoT设备` 的叶子节点
- [ ] 渲染为设备列表/表格
### W3.2 实时值轮询
- [ ] 对每个 IoT 设备调用 `gwGet('/api/gateway/realtime/{adapter}/{deviceId}')`
- [ ] 每 5s 轮询一次(`setInterval`
- [ ] 数据绑定到 ECharts 曲线图
- [ ] 数值异常时高亮显示
### W3.3 构建验证
- [ ] 环境页面显示 MC4.0 IoT 设备列表
- [ ] 选中设备 → 曲线图实时更新温度/湿度值
> **W3 提交点**: `PhaseW3_iot — IoT设备列表 + 5s轮询 + ECharts实时曲线`
---
## Phase W4: 设备详情页改造(预计 2h
### W4.1 设备类型识别
- [ ] 编辑 `warehouse/src/view/DeviceInfo.vue`
- [ ] 从 props 或全局状态获取当前选中设备(`StandardDevice`
- [ ] 根据 `DeviceCategory` + `DeviceGroup` 决定显示哪些选项卡
### W4.2 实时画面 Tab视频设备
- [ ] 视频设备:调 B6a 获取流地址 → `<video>` 播放
- [ ] 替代当前 `randomVideoImage` 占位图
### W4.3 实时曲线 TabIoT 设备)
- [ ] IoT 设备:调 B4 获取实时值 → ECharts 曲线
- [ ] 替代当前 Mock 数据生成的图表
### W4.4 设备控制 Tab
- [ ] 调 B5 发送控制指令
- [ ] 替代当前 Mock `handleTurnOn/handleTurnOff`
### W4.5 构建验证
- [ ] 地图点击设备 → 弹窗显示正确选项卡
- [ ] 视频设备显示真实画面
- [ ] IoT 设备显示实时曲线
> **W4 提交点**: `PhaseW4_device_info — 设备详情全选项卡对接真实数据`
---
## Phase W5: 报警页面对接(预计 2h
### W5.1 告警列表
- [ ] 编辑 `warehouse/src/view/emergency-alarm/EmergencyAlarmRecord.vue`
- [ ] 编辑 `warehouse/src/view/intrusion-alarm/AlarmRecord.vue`
- [ ] 页面加载时调用 `gwGet('/api/gateway/alarms/{adapter}?from=&to=&page=&size=')`
- [ ] 渲染告警表格(时间/等级/描述/状态)
### W5.2 告警确认 + 结束
- [ ] 增加"确认"和"结束"按钮
- [ ]`gwPost('/api/gateway/alarms/{adapter}/{alarmId}/confirm')`
- [ ]`gwPost('/api/gateway/alarms/{adapter}/{alarmId}/end')`
### W5.3 告警颜色映射
- [ ] 提示=蓝色、普通=黄色、重要=橙色、紧急=红色
### W5.4 告警联动地图
- [ ] 点击告警报文 → 地图定位到对应设备位置(通过 base_device.Location/Lat/Lng
### W5.5 构建验证
- [ ] 告警列表显示 MC4.0/Owl 真实告警
- [ ] 确认/结束按钮可用
- [ ] 点击告警 → 地图跳转
> **W5 提交点**: `PhaseW5_alarm — 真实告警列表 + 确认/结束 + 地图联动`
---
## Phase W6: 3D 地图设备标记(预计 3h
### W6.1 设备标记数据
- [ ] 编辑 `warehouse/src/view/DataView.vue`
- [ ] 初始化时调 VolPro API 获取 `base_device WHERE Enable=启用`
- [ ] 遍历设备,提取 `MapModelId``MapModelScale``MapModelRotation`
### W6.2 VgoMap 加载设备模型
- [ ] 调用 `window.$map.addModel({ modelId, position, scale, rotation })`API 需验证)
- [ ] 无 MapModelId 的设备用默认图标标记
- [ ] 在线设备绿色、离线设备灰色
### W6.3 点击标记 → 设备详情
- [ ] 监听 VgoMap 的 `click` 事件
- [ ] 命中设备标记时弹出 DeviceInfo.vue
### W6.4 告警设备闪烁
- [ ] 收到告警推送时,对应设备标记红色闪烁 10s
- [ ] 确认/结束告警后恢复
### W6.5 构建验证
- [ ] 3D 地图中显示设备模型或标记
- [ ] 点击标记弹出设备详情
- [ ] 告警设备红色闪烁
> **W6 提交点**: `PhaseW6_map — 3D地图设备标记 + 点击弹窗 + 告警闪烁`
---
## Phase W7: 联调 + 异常处理(预计 3h
### W7.1 异常兜底
- [ ] 网关不可达时显示"网关连接失败"提示
- [ ] 取流失败时显示"无法获取流地址"
- [ ] 网络超时 10s 后自动重试一次
### W7.2 限流保护
- [ ] IoT 轮询间隔 ≥ 5s
- [ ] 视频墙并发取流 ≤ 4 路
- [ ] 告警列表分页 ≤ 100 条/页
### W7.3 全链路联调
- [ ] 网关启动 → 管理端添加设备 → warehouse 大屏可见
- [ ] 视频实时播放 + 云台控制
- [ ] IoT 曲线实时更新
- [ ] 告警触发 → 大屏弹窗 + 地图闪烁
### W7.4 性能验证
- [ ] 大屏帧率 ≥ 30fps
- [ ] 视频延迟 ≤ 2s
- [ ] 页面首次加载 ≤ 5s
> **W7 提交点**: `PhaseW7_integration — 全链路联调通过 + 异常处理 + 限流保护`
---
> **总周期**: W0-W7 预计 16 小时(含 3h 联调)

View File

@@ -0,0 +1,251 @@
# warehouse 客户端改造方案 v1.0
> **版本**: 1.0
> **日期**: 2025-05-17
> **现状**: warehouse 大屏客户端使用硬编码 Mock 数据,尚未对接网关
> **目标**: 所有子系统(视频/IoT/告警/地图)通过 IntegrationGateway 获取真实数据
---
## 1. 仓库客户端现状分析
### 1.1 技术栈
| 层面 | 选型 |
|------|------|
| 框架 | Vue 3 + TypeScript + Vite |
| UI | Element Plus |
| 图表 | ECharts |
| 实时通信 | SignalR |
| 3D 地图 | VgoMap SDK |
| HTTP | Axios`api/http.js` |
| 状态管理 | Pinia + Vuex |
| API 基址 | `window.apiConfig.baseURL`(默认 `http://localhost:9100` |
### 1.2 页面清单与数据现状
| 页面 | 文件 | 当前数据源 | 需改造 |
|------|------|------|:---:|
| 3D 地图 | `view/Map.vue` | VgoMap SDK无设备标记 | ✅ |
| 数据大屏 | `view/DataView.vue` | VolPro API安全提示等+ Mock | ✅ |
| 实时视频 | `view/video/Live.vue` | **硬编码 5 个 Mock 摄像头** | ✅ |
| 视频墙 | `view/video/VideoWall.vue` | 需查看 | ❓ |
| 录像回放 | `view/video/History.vue` | 需查看 | ❓ |
| 设备详情 | `view/DeviceInfo.vue` | Props 传入 Mock 数据 | ✅ |
| 环境变量 | `view/environment/EnvVarManagement.vue` | 需查看 | ✅ |
| 紧急报警 | `view/emergency-alarm/EmergencyAlarmRecord.vue` | Mock / VolPro API | ✅ |
| 入侵报警 | `view/intrusion-alarm/AlarmRecord.vue` | Mock | ✅ |
| 门禁 | `view/access/AccessRecord.vue` | Mock | ⏭️ Phase 3 |
| 巡更 | `view/patrol/` | Mock | ⏭️ Phase 3 |
| 钥匙 | `view/key/` | Mock | ⏭️ Phase 3 |
| 车辆 | `view/carmanager/` | Mock | ⏭️ Phase 3 |
| 访客 | `view/visitor/` | Mock | ⏭️ Phase 3 |
| 无人机 | `view/drone/` | Mock | ⏭️ Phase 3 |
| 对讲 | `view/intercom/` | Mock | ⏭️ Phase 3 |
### 1.3 HTTP 层现状
`api/http.js` 封装了 Axios通过 `window.apiConfig.baseURL` 配置后端地址(默认 `http://localhost:9100`),支持 POST/GET 两种方式,自动附带 JWT Token。网关接口`:5100`)和 Vol.Pro 接口(`:9100`)同域还是跨域取决于部署架构。
---
## 2. 数据流改造
### 2.1 架构决策:直连网关 vs 经 Vol.Pro 中转
| 方案 | 路径 | 优点 | 缺点 |
|------|------|------|------|
| A. 直连网关 | warehouse → GW(:5100) | 延迟低,零额外开发 | 需网关暴露 CORS |
| B. 经 Vol.Pro 中转 | warehouse → VP(:9100) → GW(:5100) | 统一鉴权,网关不外露 | 延迟 +1 跳,需写 Controller |
**推荐方案 A**warehouse 客户端与大屏同一网络,网关开 CORS 即可。视频流和实时数据对延迟敏感,不宜多一跳。
### 2.2 服务路由总览
```
warehouse 客户端
├── Vol.Pro API (axios baseURL) → http://localhost:9100 (权限/字典/CRUD)
└── 网关 B 接口 (直接 fetch) → http://localhost:5100 (设备/视频/IoT/告警)
```
---
## 3. 逐页面改造方案
### 3.1 实时视频Live.vue
**现状**:硬编码 5 个 Mock 摄像机,无真实流
**改造**
1. 页面加载时调用网关 `GET /api/gateway/devices?adapter=Owl:main&page=1&size=100`
2. 摄像机列表从 `StandardDevice[]` 映射到 `Camera[]` 接口
3. 选中摄像机后调用 `GET /api/gateway/streams/{adapterCode}/{sourceId}/live` 获取 WS-FLV 地址
4.`<video>` 标签 `autoplay muted` 播放(替换占位 div
5. 增加云台方向控制面板(复用 `base_device.vue` 的云台 UI
6. 增加录像回放入口(调 B6b 获取 HLS 地址)
**改动量**~150 行
### 3.2 视频墙VideoWall.vue+ 回放History.vue
**现状**:需查看文件
**改造**
- 视频墙:多路视频同屏,从网关获取多个通道的流地址并行播放
- 回放:时间轴选择 → `GET /api/gateway/streams/{adapter}/{id}/playback?start=&end=`
**改动量**~100 行
### 3.3 环境变量EnvVarManagement.vue
**现状**:需查看文件,预期是表格/图表展示温湿度等
**改造**
1. 从网关获取 MC4 IoT 设备列表:`GET /api/gateway/tree?adapter=MC4:31ku`
2. 筛选 `DeviceGroup=IoT设备` 的设备
3. 轮询 `GET /api/gateway/realtime/{adapter}/{deviceId}` 获取实时值
4. 替换 Mock 数据,驱动 ECharts 曲线图实时更新
**改动量**~120 行
### 3.4 设备详情DeviceInfo.vue
**现状**:从 DataView.vue 接收 props展示设备基础信息 + 实时画面(占位图)+ 曲线Mock+ 控制按钮
**改造**
1. 设备类型判断从 mock type → `row.DeviceCategory` 字段
2. "实时画面" Tab — 视频设备时调网关 B6a 显示真实流
3. "实时曲线" Tab — IoT 设备时调网关 B4 获取实时值
4. "设备控制" Tab — 调网关 B5 写值
5. 在线率/达标率 — 从 `row.IsOnline` 和实时值计算
**改动量**~200 行
### 3.5 紧急报警 + 入侵报警
**现状**:部分调 VolPro API部分 Mock
**改造**
1. 告警列表从网关 `GET /api/gateway/alarms/{adapter}?from=&to=` 获取
2. 确认/结束告警 → `POST /api/gateway/alarms/{adapter}/{alarmId}/confirm`
3. 告警等级颜色映射(提示/普通/重要/紧急)
4. 点击告警行 → 联动地图定位到设备
**改动量**~100 行
### 3.6 3D 地图Map.vue / DataView.vue
**现状**VgoMap 加载 3D 模型,无设备标记
**改造**
1. DataView.vue 初始化时调 VolPro API 获取 `base_device WHERE Enable=启用` 的设备列表
2. 遍历设备,读取 `MapModelId``MapModelScale``MapModelRotation` 字段
3. 调用 VgoMap SDK 在 3D 场景中放置设备标记(`map.addMarker` 或模型加载 API
4. 设备在线状态 → 标记颜色(绿/灰)
5. 点击标记 → 弹出 DeviceInfo.vue 卡片
6. 告警设备 → 红色闪烁标记
**改动量**~150 行
---
## 4. 数据模型映射
### 4.1 StandardDevice → Camera
```typescript
interface Camera {
id: string // StandardDevice.SourceId
name: string // StandardDevice.Name
location: string // StandardDevice.Extra?.location || ''
status: string // StandardDevice.IsOnline ? 'online' : 'offline'
adapterCode: string // StandardDevice.AdapterCode (用于后续 API 调用)
streamUrl?: string // 通过 B6a 动态获取
hasPtz: boolean // StandardDevice.Extra?.hasPtz === '1'
}
```
### 4.2 StandardDevice → MapMarker
```typescript
interface MapMarker {
deviceId: number // StandardDevice.DeviceId
modelId: string // StandardDevice.MapModelId
scale: number // StandardDevice.MapModelScale
rotation: JSON // StandardDevice.MapModelRotation
isOnline: boolean // StandardDevice.IsOnline
category: string // StandardDevice.DeviceCategory
}
```
### 4.3 PointValue → ChartData
```typescript
interface ChartSeries {
name: string // `点位${pv.PointIndex}`
data: number[] // [pv.Value, ...] (时间序列)
timeLabels: string[] // [pv.UpdateTime, ...]
}
```
---
## 5. API 封装层
### 5.1 新增 `api/gateway.js`
```typescript
const GW_BASE = 'http://localhost:5100';
export async function gwGet(url: string) {
const resp = await fetch(`${GW_BASE}${url}`);
return resp.json();
}
export async function gwPost(url: string, body?: object) {
const resp = await fetch(`${GW_BASE}${url}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined
});
return resp.json();
}
```
### 5.2 现有 `api/http.js` 不修改
Vol.Pro 后端 API权限、字典、CRUD、安全提示继续走 `api/http.js`,不加网关逻辑。
---
## 6. 与 VolPro 管理端的职责分工
| 功能 | 管理端web.vite | 大屏端warehouse |
|------|:---:|:---:|
| 设备 CRUD | ✅ 设备管理页面 | ❌ |
| 网关注册/心跳 | ✅ A1-A4 | ❌ |
| 区域树管理 | ✅ GetRegionTree | ❌ |
| 实时视频播放 | ✅ 预览按钮 | ✅ 多路视频墙 |
| 云台控制 | ✅ 方向键 | ✅ 方向键 |
| 视频回放 | ✅ 回放按钮 | ✅ 时间轴回放 |
| IoT 实时曲线 | ❌ | ✅ ECharts 大屏 |
| 告警弹窗联动 | ❌ | ✅ 告警+地图+预案 |
| 3D 模型绑定 | ✅ 编辑面板 | ✅ 加载标记 |
---
## 7. 实施计划
| 阶段 | 内容 | 预计工时 |
|------|------|:---:|
| W1 | 新增 `api/gateway.js` + 实时视频页改造 | 2h |
| W2 | 视频墙 + 录像回放改造 | 2h |
| W3 | 环境变量 IoT 实时数据改造 | 2h |
| W4 | 设备详情页对接真实数据 | 2h |
| W5 | 报警页面对接网关告警 | 2h |
| W6 | 3D 地图加载设备标记 | 3h |
| W7 | 联调 + 异常处理 + 限流 | 3h |
---
> **风险**: 网关 CORS 配置、VgoMap SDK 设备标记 API 需要验证

View File

@@ -0,0 +1,550 @@
# IntegrationGateway B 组接口文档
> **版本**: 1.0
> **日期**: 2026-06-04
> **基址**: `http://{host}:{port}`(默认 `http://localhost:5100`
> **内容类型**: `application/json`(除标注外)
> **认证**: 可选 `X-Gateway-Key` 请求头(与 Gateway 段配置一致时生效)
> **通用错误码**: 见 §5
---
## 目录
1. [健康检查](#1-健康检查) — B1
2. [设备管理](#2-设备管理) — B2, B3, B3-sync
3. [视频与流媒体](#3-视频与流媒体) — B6a, B6b, 截图, B7
4. [IoT 实时数据](#4-iot-实时数据) — B4, B4-batch, B5
5. [告警管理](#5-告警管理) — B8, B9-confirm, B9-end
6. [录像查询](#6-录像查询)
7. [设备控制 (通用)](#7-设备控制-通用) — B10
8. [业务记录查询](#8-业务记录查询) — B11
9. [数据同步](#9-数据同步) — B12, B13
10. [错误代码](#10-错误代码)
---
## 1. 健康检查
### B1: 查询所有适配器健康状态
```
GET /api/gateway/health
```
**请求参数**: 无
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `[].adapterCode` | string | 适配器编码,如 `Owl:main``MC4:31ku``KMS:main` |
| `[].displayName` | string | 人类可读的适配器名称 |
| `[].healthy` | bool | `true` = 适配器在线,`false` = 离线或不可达 |
| `[].capabilities` | object | 适配器能力声明 |
**capabilities 字段**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `hasFlatDevices` | bool | 是否支持扁平设备列表 |
| `hasOwnDeviceTree` | bool | 是否支持层级对象树 |
| `hasStreams` | bool | 是否支持视频取流 |
| `hasPoints` | bool | 是否支持 IoT 实时点位 |
| `hasAlarms` | bool | 是否支持告警查询 |
| `hasRecordings` | bool | 是否支持录像查询 |
**返回示例**:
```json
[
{
"adapterCode": "Owl:main",
"displayName": "Owl (Owl:main)",
"healthy": true,
"capabilities": { "hasFlatDevices": true, "hasStreams": true, "hasAlarms": true, "hasRecordings": true }
}
]
```
---
## 2. 设备管理
### B2: 分页获取扁平设备列表
```
GET /api/gateway/devices?adapter={adapterCode}&page={page}&size={size}&keyword={keyword}
```
**请求参数**:
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|:--:|------|------|
| `adapter` | string | ✅ | — | 适配器编码,如 `Owl:main` |
| `page` | int | ❌ | 1 | 页码(从 1 开始) |
| `size` | int | ❌ | 20 | 每页条数 |
| `keyword` | string | ❌ | null | 设备名称模糊搜索 |
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `items` | array | 设备列表 |
| `total` | int | 总设备数 |
**items[].StandardDevice**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `sourceId` | string | 子系统设备原始 ID |
| `name` | string | 设备名称 |
| `category` | string | 设备种类(如 `硬盘录像机``摄像头``智能钥匙柜``钥匙位` |
| `group` | string | 设备分组(`视频设备`/`IoT设备`/`门禁设备` |
| `isParent` | bool | 是否父设备(含下级子设备) |
| `parentSourceId` | string? | 上级设备 SourceId |
| `isOnline` | bool | 是否在线 |
| `ipAddress` | string? | IP 地址 |
| `port` | int? | 端口号 |
| `extra` | object? | 子系统特有扩展属性 |
**返回示例**:
```json
{
"items": [
{ "sourceId": "locker_25", "name": "10位智能公共钥匙柜", "category": "智能钥匙柜", "group": "门禁设备", "isParent": true, "isOnline": true },
{ "sourceId": "lockhole_25_1", "name": "仓库大门钥匙", "category": "钥匙位", "group": "门禁设备", "isParent": false, "isOnline": true, "parentSourceId": "locker_25" }
],
"total": 11
}
```
### B3: 获取层级对象树
```
GET /api/gateway/tree?adapter={adapterCode}
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码,如 `MC4:31ku` |
**返回参数**: `DeviceTreeNode[]`
| 字段 | 类型 | 说明 |
|------|------|------|
| `sourceId` | string | 节点 ID |
| `name` | string | 节点名称 |
| `tag` | string | 节点标签(如 `区域`/`设备组`/`IoT设备` |
| `type` | int | 节点类型1=父节点, 0=叶子节点 |
| `children` | array | 子节点列表(递归) |
| `option` | object? | 扩展配置 |
### B3-sync: 手动触发设备同步
```
POST /api/gateway/devices/sync?adapter={adapterCode}
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码 |
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `deviceCount` | int | 同步设备数(扁平设备) |
| `nodeCount` | int | 同步节点数(对象树) |
| `message` | string | 同步结果描述 |
---
## 3. 视频与流媒体
### B6a: 获取实时流地址
```
GET /api/gateway/streams/{adapter}/{deviceId}/live
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码,如 `Owl:main` |
| `deviceId` | string | ✅ | 通道 SourceId |
**返回参数 (StreamUrls)**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `wsFlv` | string? | WebSocket-FLV 地址(推荐,低延迟) |
| `httpFlv` | string? | HTTP-FLV 地址 |
| `hls` | string? | HLS (m3u8) 地址 |
| `webrtc` | string? | WebRTC 地址 |
| `rtmp` | string? | RTMP 地址 |
### B6b: 获取录像回放地址
```
GET /api/gateway/streams/{adapter}/{deviceId}/playback?start={start}&end={end}
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码 |
| `deviceId` | string | ✅ | 通道 SourceId |
| `start` | DateTime | ✅ | 回放起始时间 (ISO 8601) |
| `end` | DateTime | ✅ | 回放结束时间 (ISO 8601) |
**返回**: 同 `StreamUrls`
### 截图: 获取通道实时截图
```
POST /api/gateway/streams/{adapter}/{deviceId}/snapshot
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码 |
| `deviceId` | string | ✅ | 通道 SourceId |
**返回**: JPEG 图片 Base64 或 URL
### B7: 云台控制
```
POST /api/gateway/streams/{adapter}/{deviceId}/ptz
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码 |
| `deviceId` | string | ✅ | 通道 SourceId |
**请求体 (PtzRequest)**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `direction` | string | ❌ | 方向:`up`/`down`/`left`/`right`/`zoom_in`/`zoom_out` |
| `action` | string | ✅ | 动作:`continuous`(持续)/`stop`(停止)/`preset`/`patrol` |
| `speed` | float | ❌ | 速度 (0.1~1.0),默认 0.5 |
---
## 4. IoT 实时数据
### B4: 获取设备实时点位值
```
GET /api/gateway/realtime/{adapter}/{deviceId}
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码,如 `MC4:31ku` |
| `deviceId` | string | ✅ | 设备 SourceId |
**返回参数**: `PointValue[]`
| 字段 | 类型 | 说明 |
|------|------|------|
| `pointIndex` | int | 点位索引 |
| `pointName` | string | 点位名称 |
| `value` | decimal | 当前值 |
| `unit` | string? | 单位(如 `℃``%` |
| `updateTime` | DateTime | 更新时间 |
| `interval` | int | 采集间隔(秒) |
### B4-batch: 批量获取实时点位值
```
POST /api/gateway/realtime/{adapter}/batch
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码 |
**请求体 (BatchRealtimeRequest)**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `deviceIds` | string[] | ✅ | 设备 SourceId 列表 |
**返回**: `Dictionary<string, PointValue[]>` — 以 deviceId 为键的实时值字典
### B5: 设备反向控制
```
POST /api/gateway/realtime/{adapter}/control
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码 |
**请求体 (ControlRequest)**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `deviceId` | string | ✅ | 设备 SourceId |
| `pointIndex` | int | ✅ | 目标点位索引 |
| `value` | double | ✅ | 目标值(如开关 0/1温度设定值等 |
---
## 5. 告警管理
### B8: 分页查询告警列表
```
GET /api/gateway/alarms/{adapter}?page={page}&size={size}&from={from}&to={to}&level={level}&state={state}
```
**请求参数**:
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|:--:|------|------|
| `adapter` | string | ✅ | — | 适配器编码 |
| `page` | int | ❌ | 1 | 页码 |
| `size` | int | ❌ | 20 | 每页条数 |
| `from` | DateTime | ❌ | MinValue | 告警起始时间 |
| `to` | DateTime | ❌ | MinValue | 告警结束时间 |
| `level` | string | ❌ | null | 告警等级过滤 |
| `state` | string | ❌ | null | 告警状态过滤:`未确认`/`已确认`/`已结束` |
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `items` | array | 告警列表 |
| `total` | int | 总告警数 |
**items[].StandardAlarm**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `alarmId` | string | 告警 ID |
| `adapterCode` | string | 适配器编码 |
| `deviceId` | string? | 关联设备 ID |
| `level` | string | 告警等级:`提示`/`普通`/`重要`/`紧急` |
| `title` | string | 告警标题 |
| `content` | string? | 告警详细内容 |
| `occurTime` | DateTime | 发生时间 |
| `status` | string | 状态:`未确认`/`已确认`/`已结束` |
| `actualValue` | string? | 实际值(超标告警) |
| `thresholdValue` | string? | 阈值(超标告警) |
### B9-confirm: 确认告警
```
POST /api/gateway/alarms/{adapter}/{alarmId}/confirm
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码 |
| `alarmId` | string | ✅ | 告警 ID子系统告警源 ID |
### B9-end: 结束告警
```
POST /api/gateway/alarms/{adapter}/{alarmId}/end
```
**请求参数**: 同 B9-confirm
---
## 6. 录像查询
```
GET /api/gateway/recordings/{adapter}/{deviceId}?start={start}&end={end}&page={page}&size={size}
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码 |
| `deviceId` | string | ✅ | 通道 SourceId |
| `start` | DateTime | ✅ | 录像起始时间 |
| `end` | DateTime | ✅ | 录像结束时间 |
| `page` | int | ❌ | 页码,默认 1 |
| `size` | int | ❌ | 每页条数,默认 20 |
**返回**: 录像文件列表(含文件名、起止时间、时长、大小)
---
## 7. 设备控制 (通用)
### B10: 下发控制指令
```
POST /api/gateway/control/{adapter}
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码,如 `KMS:main` |
**请求体 (GatewayControlRequest)**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `deviceId` | string | ✅ | 设备 SourceId |
| `command` | string | ✅ | 指令名:`open`(开门)/`close`(关门)/`authorize`(授权) |
| `parameters` | object | ❌ | 指令参数,如 `{"staffIds": [1,2], "lockholeSort": 3}` |
**返回 (ControlResult)**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `success` | bool | 操作是否成功 |
| `message` | string? | 失败时的错误信息 |
---
## 8. 业务记录查询
### B11: 查询子系统业务记录
```
GET /api/gateway/logs/{adapter}?logType={logType}&from={from}&to={to}&page={page}&size={size}
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码 |
| `logType` | string | ✅ | 记录类型:`borrow`(借还)/`handover`(交接)/`permission`(授权) |
| `from` | DateTime | ❌ | 起始时间 |
| `to` | DateTime | ❌ | 结束时间 |
| `page` | int | ❌ | 页码,默认 1 |
| `size` | int | ❌ | 每页条数,默认 20 |
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `items` | array | 业务记录列表 |
| `total` | int | 总记录数 |
**items[].BusinessLogEntry**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `logId` | string | 记录唯一 ID |
| `logType` | string | 记录类型 |
| `deviceSourceId` | string? | 关联设备 SourceId |
| `staffName` | string? | 关联员工姓名 |
| `description` | string? | 记录描述 |
| `createdAt` | DateTime? | 记录时间 |
| `extra` | object? | 扩展属性 |
---
## 9. 数据同步
### B12: 向子系统写入数据
```
POST /api/gateway/sync/{adapter}
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码 |
**请求体 (SyncRequest)**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `dataType` | string | ✅ | 数据类型,当前支持 `staff`(员工) |
| `items` | object[] | ✅ | 待同步数据列表 |
**返回 (SyncResult)**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `successCount` | int | 成功数量 |
| `failCount` | int | 失败数量 |
| `message` | string? | 错误信息 |
### B13: 从子系统删除数据
```
DELETE /api/gateway/sync/{adapter}
```
**请求参数**: 同 B12
**请求体 (SyncDeleteRequest)**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `dataType` | string | ✅ | 数据类型,当前支持 `staff` |
| `ids` | string[] | ✅ | 待删除 ID 列表 |
**返回**: 同 `SyncResult`
---
## 10. 错误代码
### 通用 HTTP 状态码
| 状态码 | 含义 | 触发条件 |
|:---:|------|------|
| 200 | OK | 请求成功 |
| 400 | Bad Request | 请求参数格式错误 |
| 401 | Unauthorized | `X-Gateway-Key` 缺失或不匹配 |
| 404 | Not Found | 适配器不存在或不支持该能力 |
| 500 | Internal Server Error | 适配器内部异常 |
| 502 | Bad Gateway | 子系统返回错误或不可达 |
### 业务错误码
所有非 200 响应包含 JSON body
| 字段 | 类型 | 说明 |
|------|------|------|
| `error` | string | 错误码 |
| `message` | string? | 人类可读的错误详情 |
| error 值 | HTTP | 说明 |
|------|:---:|------|
| `ADAPTER_NOT_FOUND` | 404 | 指定适配器编码不存在 |
| `CAPABILITY_NOT_SUPPORTED` | 404 | 适配器不支持该接口能力 |
| — (control 接口) | 502 | `ControlResult.Success=false` 时返回 `ControlResult.Message` |
---
> **接口总数**: 19 个 REST 端点
> **适配器**: Owl / MC4 / KMS通过 `adapter` 参数路由)

View File

@@ -0,0 +1,223 @@
# 网关 KMS 模块检查报告 2026-06-04
> **基准文档**: `doc/对接文档/钥匙管理系统软件接口.docx` (KMS API v1.0.4)
> **检查范围**: `gateway/src/IntegrationGateway.Adapters.Kms/` (KmsAdapter.cs / KmsAuthHelper.cs / KmsModels.cs) + `Program.cs` B10-B13 路由
> **方法**: 逐接口比对文档 → 代码 → 路由
---
## 1. 覆盖率总览
| 模块 | KMS 文档端点数 | Gateway 覆盖 | 覆盖率 |
|------|:---:|:---:|:---:|
| 2.9 Token 获取 | 1 | 1 | 100% |
| 2.18 开放接口 | 8 | 8 | 100% |
| **总计 (Phase 1)** | **9** | **9** | **100%** |
| 2.3-2.17 标准接口 | 38 | 1 (确认告警) | 3% |
---
## 2. 逐接口检查
### 2.18.1 心跳 — `GET /prod-api/heartBeat`
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|------|------|------|:--:|
| 请求方法 | `client.GetAsync(...)` | GET | ✅ |
| 请求路径 | `/prod-api/heartBeat` | `/prod-api/heartBeat` | ✅ |
| 请求体 | 无 | 无 | ✅ |
| 错误处理 | `catch (Exception ex) { Console.Error.WriteLine; return false; }` | — | ✅ |
### 2.18.2 批量删除员工 — `POST /prod-api/batchDeleteStaff`
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|------|------|------|:--:|
| 请求方法 | `PostAsJsonAsync(...)` | POST | ✅ |
| 请求路径 | `/prod-api/batchDeleteStaff` | `/prod-api/batchDeleteStaff` | ✅ |
| 请求体 | `List<string>` (staffUuid 数组) | `["uuid1","uuid2",...]` | ✅ |
| 参数类型 | 数组 | 数组 (v1.0.2 修正) | ✅ |
### 2.18.3 批量同步员工 — `POST /prod-api/batchSyncStaff`
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|------|------|------|:--:|
| 请求方法 | `PostAsJsonAsync(...)` | POST | ✅ |
| 请求路径 | `/prod-api/batchSyncStaff` | `/prod-api/batchSyncStaff` | ✅ |
| 请求体 | `new { staff = staffList }` | staff 数组 | ⚠️ |
| account 字段 | 模型中有 Account? | v1.0.4 新增 account | ⚠️ 待验证 |
**风险**: 文档 v1.0.4 新增了 `account` (登录账号) 字段。`KmsStaff` 模型需确认包含此字段。网关包装为 `{ staff: [...] }` 可能与 KMS 期望的裸数组不一致。
### 2.18.4 查询柜体钥匙 — `POST /prod-api/getOpenerList`
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|------|------|------|:--:|
| 请求方法 | POST | POST | ✅ |
| 请求路径 | `/prod-api/getOpenerList` | `/prod-api/getOpenerList` | ✅ |
| 请求体 | `"{}"` | 无明确要求 / 空对象 | ✅ |
| 响应 → StandardDevice | 柜体→父设备, 锁孔→子设备 | 树状结构 | ✅ |
### 2.18.5 查询授权记录 — `POST /prod-api/getPermissionList`
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|------|------|------|:--:|
| 请求方法 | POST | POST | ✅ |
| 请求路径 | `/prod-api/getPermissionList` | `/prod-api/getPermissionList` | ✅ |
| 请求体 | `"{}"` | 授权记录业务对象 (含 lockerName, lendStaffName 等 20+ 字段) | 🔴 |
| 时间范围 | `DateTime? from, DateTime? to` 参数**未传入请求体** | `beginApplyTime`/`endApplyTime` | 🔴 |
| 分页 | `page`/`size` 参数**未传入请求体** | `pageNum`/`pageSize` | 🔴 |
**致命问题**: 网关注入 `from`/`to`/`page`/`size` 参数但**从未传入 KMS 请求体**。代码注释 `// 联调时加入时间范围` 确认这是已知缺口。当前实现等价于无过滤全量查询,无法按时间范围分页。
### 2.18.6 查询借还记录 — `POST /prod-api/getRecordList`
**与 2.18.5 完全相同的致命问题**: `from`/`to`/`page`/`size` 参数未传入 KMS 请求体。此外文档标记 `lockerName``lockholeSort``openerCnName` 为必填字段,但网关传 `"{}"` 无这些字段。
### 2.18.7 查询告警记录 — `POST /prod-api/getWarningList`
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|------|------|------|:--:|
| 请求方法 | POST | POST | ✅ |
| 请求路径 | `/prod-api/getWarningList` | `/prod-api/getWarningList` | ✅ |
| 请求体 | `"{}"` | 告警业务对象 (含 type, beginWarningTime 等) | 🔴 |
| 时间范围 | 未传 | `beginWarningTime`/`endWarningTime` | 🔴 |
| 告警类型 | 未传 (type=1当前/2历史) | 文档支持过滤 | 🔴 |
| 响应映射 | `Type==1 ? "未确认" : "已结束"` | `type` 1=当前告警, 2=历史告警 | 🔴 |
**状态映射错误**: `type` 字段在告警接口中表示 1=当前告警 / 2=历史告警,**不是** 1=未确认 / 2=已结束。代码将 type=1 映射为 Status="未确认"、type=2 映射为 Status="已结束",语义错误。正确的映射应该是 type=1 → "活跃", type=2 → "历史"。
### 2.18.8 单点登录 — `POST /thirdPlatlogin`
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|------|------|------|:--:|
| 请求方法 | POST | POST | ✅ |
| 请求路径 | `/thirdPlatlogin?username={x}` | `/thirdPlatlogin?username={x}` | ✅ |
| 重定向处理 | 捕获 302, 返回 Location header | 文档说明"调用成功后直接重定向" | ✅ |
| 超时 | 无显式设置 | — | 🟡 |
### 2.9 Token 获取 — `POST /prod-api/getToken`
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|------|------|------|:--:|
| 请求方法 | `http.PostAsync(url, null)` | POST | ✅ |
| 参数位置 | query string: `?clientId=&clientSecret=` | query string | ✅ |
| 响应校验 | `Code != 200` → 抛异常 | `code: 200` = 成功 | ✅ |
| 缓存策略 | 25分钟 (30分钟效期-5分钟余量) | 30分钟效期 | ✅ |
> **注意**: 文档 2.9.1 显示 `POST /prod-api/getToken` 参数在 body 中 (`{ clientId, clientSecret }`),但 2.9 节概述描述为 query 参数。两种方式 KMS 可能都支持。当前实现用 query string联调时需确认兼容。
---
## 3. 模型映射核对
### 3.1 KMS 柜体 → StandardDevice
| 文档字段 | 代码映射 | 准确性 |
|------|------|:--:|
| lockerId | `SourceId = $"locker_{lockerId}"` | ✅ |
| lockerName | `Name = lockerName` | ✅ |
| lockerCode | `Extra["lockerCode"]` | ✅ |
| lockholeList | 遍历展开为子设备 | ✅ |
| IsParent | `true` | ✅ |
### 3.2 KMS 锁孔 → StandardDevice
| 文档字段 | 代码映射 | 准确性 |
|------|------|:--:|
| lockholeSort | `SourceId = $"lockhole_{lockerId}_{lockholeSort}"` | ✅ |
| openerName | `Name = openerName` | ✅ |
| openerType | `Extra["openerType"]` (1/2/3 数值) | ✅ |
| openerState | `Extra["openerState"]` + `IsOnline = (openerState=="在位")` | 🔴 |
| ParentSourceId | `$"locker_{lockerId}"` | ✅ |
**openerState 映射错误**: 根据文档数据字典§4`openerState` 是数值编码:
- 1 = 在柜
- 2 = 借出
- 3 = 录入
- 10 = 丢失
代码用 `openerState == "在位"` 做字符串比较,**永远不成立**。需改为 `openerState == "1"` 或解析为 int 后判断。
### 3.3 KMS 借还记录 → BusinessLogEntry
| 文档字段 | 代码映射 | 准确性 |
|------|------|:--:|
| uuid | `LogId` | ✅ |
| lockerName | 拼入 `DeviceSourceId` | ✅ |
| staffName | `StaffName` | ✅ |
| borrowTime | `CreatedAt` | ✅ |
| openerName | `Description` (不充分) | 🟡 |
### 3.4 KMS 告警 → StandardAlarm
| 文档字段 | 代码映射 | 准确性 |
|------|------|:--:|
| uuid | `AlarmId` | ✅ |
| warningTime | `OccurTime` | ✅ |
| remark | `Content` | ✅ |
| type (1/2) | `Status = Type==1 ? "未确认""已结束"` | 🔴 |
| level | 固定 `"普通"` | 🟡 |
**type 语义错误**: 见 2.18.7 说明。文档明确 `type` 表示告警分类(1=当前,2=历史),而非确认状态。
---
## 4. B 路由链路检查
| B 路由 | 对应 KMS 能力 | 适配器方法 | 参数传递 | 状态 |
|------|------|------|:--:|:--:|
| B1 `/health` | 心跳 2.18.1 | `HealthCheckAsync` | ✅ | ✅ |
| B2 `/devices` | 柜体钥匙 2.18.4 | `GetDevicesAsync` | ✅ | ✅ |
| B8 `/alarms` | 告警 2.18.7 | `GetAlarmsAsync` | ✅ | ✅ (映射有误) |
| B9 `/alarms/{id}/confirm` | 确认告警 | `ConfirmAlarmAsync` | ✅ | ⚠️ 端点未确认 |
| B10 `/control` | 远程控制 | `SendControlAsync` | ✅ | ⚠️ |
| B11 `/logs` | 业务记录 | `GetBusinessLogsAsync` | ✅ | ⚠️ |
| B12 `/sync` (POST) | 员工同步 2.18.3 | `SyncDataAsync` | ✅ | ⚠️ |
| B13 `/sync` (DELETE) | 删除员工 2.18.2 | `DeleteDataAsync` | ✅ | ✅ |
---
## 5. 问题汇总
### 🔴 致命问题 (需联调前修复)
| # | 问题 | 影响 |
|:--:|------|------|
| **R1** | 2.18.5/2.18.6/2.18.7 请求体只传 `"{}"`,忽略 `from`/`to`/`page`/`size` 参数 | 无法按时间分页查询,联调时大概率返回全量数据或报错 |
| **R2** | 2.18.7 type 字段语义错误 (1=当前告警被映射为"未确认") | 告警状态全部错误 |
| **R3** | openerState 字符串比较 vs 文档数值编码 | 所有锁孔 IsOnline 永远为 false |
### 🟠 严重问题
| # | 问题 | 影响 |
|:--:|------|------|
| **S1** | 2.18.6 必填字段 (`lockerName`, `lockholeSort`, `openerCnName`) 未传 | 借还记录查询可能被 KMS 拒绝 |
| **S2** | `ConfirmAlarmAsync` 端点 (`/prod-api/kms/warning/confirm/{id}`) 未在文档中确认存在 | 告警确认功能不可用 |
| **S3** | 2.18.5 请求体结构未知 (文档未给完整示例) | 授权记录查询格式需联调验证 |
### 🟡 改善项
| # | 问题 | 建议 |
|:--:|------|------|
| **M1** | KmsModels.cs 包含大量 Phase 2 DTO 但未使用 | 保留Phase 2 可用 |
| **M2** | B10 控制指令 `command == "open"``command == "authorize"` 都调同一方法 | 区分"开门"和"授权"两种指令 |
| **M3** | ThirdPlatLoginAsync 无超时设置 | 加 15s 超时 |
| **M4** | SyncDevicesJob 中 `gwRepo.Update(node)` vs `gwSvc.UpdateAsync(node)` 不一致 | 统一风格 |
---
## 6. 需联调验证项
| # | 验证项 | 说明 |
|:--:|------|------|
| V1 | Token 获取用 query string vs body | 文档 2.9.1 标记为 body 参数2.9 节概述为 query |
| V2 | `batchSyncStaff` body 格式 | `{ staff: [...] }` vs `[...]` |
| V3 | `KmsStaff` 是否需 account 字段 | v1.0.4 新增 |
| V4 | `getOpenerList` 返回的 openerState 是数值还是中文 | 决定映射逻辑 |
| V5 | `getRecordList` 必填字段是否真的必填 | 决定请求体最小字段集 |
| V6 | `getPermissionList` 请求体完整格式 | 文档示例不完整 |
| V7 | `warning/confirm` 端点存在性 | 调标准管理接口 |
---
> **结论**: 9 个 Phase 1 接口全部实现覆盖100%),但 3 个致命问题R1-R3需在联调前修复——核心是请求体格式、type 语义映射、openerState 编码映射。其余 4 个严重问题需联调验证后确认。

View File

@@ -0,0 +1,337 @@
# 网关 MC4 模块整改方案 v1.0
> **版本**: 1.0
> **日期**: 2026-06-03
> **基准**: `doc/设计文档/网关MC4模块检查报告20260603.md`
---
## 1. 整改总览
| 步骤 | 优先级 | 内容 | 文件 | 预计 |
|:---:|:---:|------|------|:---:|
| M1 | 🔴 P0 | Mc4AuthHelper 认证修复 | Mc4AuthHelper.cs + appsettings | 1h |
| M2 | 🟠 P1 | 批量点位查询 | Mc4Adapter.cs | 30min |
| M3 | 🟡 P2 | 历史告警查询 | Mc4Adapter.cs | 30min |
| M4 | 🟡 P2 | B4-batch 路由改用 native batch | Program.cs | 15min |
| M5 | 验证 | 编译 + 联调 | — | 30min |
| **合计** | — | — | **4 文件** | **~3h** |
---
## 2. 步骤 M1: Mc4AuthHelper 认证修复(预计 1h
### 2.1 问题
```csharp
// 当前: 调 /conf/get (返回 { "encrypt": true }),误读为 Token
var resp = await _http.PostAsync($"{_baseUrl}/api/central/auth/conf/get", null);
var result = JsonSerializer.Deserialize<Mc4AuthResponse>(json);
_token = result?.Token ?? ""; // Token 始终为 null
```
### 2.2 MC4.0 实际认证流程
```
1. POST /api/central/auth/conf/get → { "encrypt": true/false }
2. 若 encrypt=true → 密码 MD5(原始密码)
3. POST /api/central/auth/login {
"account": "admin",
"password": "md5或原始密码"
}
→ { "token": "xxx", "id": 0, "account": "admin", "name": "管理员" }
```
### 2.3 修改后的 Mc4AuthHelper
```csharp
public class Mc4AuthHelper
{
private readonly HttpClient _http;
private readonly string _baseUrl;
private readonly string _account;
private readonly string _password;
private string? _token;
private DateTime _tokenExpiry = DateTime.MinValue;
private bool? _needMd5;
public Mc4AuthHelper(HttpClient http, string baseUrl, string account, string password)
{
_http = http;
_baseUrl = baseUrl.TrimEnd('/');
_account = account;
_password = password;
}
public async Task<string> GetTokenAsync()
{
if (!string.IsNullOrEmpty(_token) && DateTime.UtcNow < _tokenExpiry)
return _token;
// 1. 获取加密配置
if (!_needMd5.HasValue)
{
var confResp = await _http.PostAsync($"{_baseUrl}/api/central/auth/conf/get", null);
if (confResp.IsSuccessStatusCode)
{
var confJson = await confResp.Content.ReadAsStringAsync();
var conf = JsonSerializer.Deserialize<Mc4ConfResponse>(confJson);
_needMd5 = conf?.Encrypt ?? false;
}
else
{
_needMd5 = false; // 失败时假定不需要加密
}
}
// 2. 登录获取 Token
var pwd = _needMd5 == true ? ComputeMd5(_password) : _password;
var loginBody = JsonSerializer.Serialize(new { account = _account, password = pwd });
var resp = await _http.PostAsync($"{_baseUrl}/api/central/auth/login",
new StringContent(loginBody, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<Mc4LoginResponse>(json)
?? throw new Exception("MC4 登录失败");
_token = result.Token ?? "";
_tokenExpiry = DateTime.UtcNow.AddHours(7); // 保守估计 8h
return _token;
}
public async Task<HttpClient> GetAuthenticatedClientAsync()
{
var token = await GetTokenAsync();
var client = new HttpClient { BaseAddress = new Uri(_baseUrl) };
if (!string.IsNullOrEmpty(token))
client.DefaultRequestHeaders.Add("token", token);
return client;
}
public void Invalidate() => _token = null;
private static string ComputeMd5(string input) { /* MD5 实现 or use System.Security.Cryptography */ }
private class Mc4ConfResponse { public bool? Encrypt { get; set; } }
private class Mc4LoginResponse { public string? Token { get; set; } public int Id { get; set; } public string? Account { get; set; } }
}
```
### 2.4 构造函数签名变更
```csharp
// 旧: public Mc4AuthHelper(HttpClient http, string baseUrl)
// 新: public Mc4AuthHelper(HttpClient http, string baseUrl, string account, string password)
```
### 2.5 Mc4Adapter 构造函数变更
```csharp
// 旧:
public Mc4Adapter(string adapterCode, HttpClient http, string baseUrl)
{
_auth = new Mc4AuthHelper(http, baseUrl);
}
// 新:
public Mc4Adapter(string adapterCode, HttpClient http, string baseUrl,
string account = "admin", string password = "admin")
{
_auth = new Mc4AuthHelper(http, baseUrl, account, password);
}
```
### 2.6 Program.cs 注册变更
```csharp
// 旧: new Mc4Adapter(code, http, m.BaseUrl)
// 新: new Mc4Adapter(code, http, m.BaseUrl,
// m.Username ?? "admin", m.Password ?? "admin")
```
### 2.7 Mc4Config 增加字段
```csharp
public class Mc4Config
{
public string? InstanceName { get; set; }
public string BaseUrl { get; set; } = "";
public string Username { get; set; } = "admin"; // 新增
public string Password { get; set; } = "admin"; // 新增
}
```
### 2.8 appsettings.json 更新
```json
"MC4": [
{ "InstanceName": "31ku", "BaseUrl": "http://localhost:3000",
"Username": "admin", "Password": "your_mc4_password" }
]
```
### 2.9 编译验证
`dotnet build gateway/IntegrationGateway.slnx` → 0 错误。
> **M1 提交点**: `Fix-M1: Mc4AuthHelper 认证修复 conf/get→login + account/password支持`
---
## 3. 步骤 M2: 批量点位查询(预计 30min
### 3.1 文件
`gateway/src/IntegrationGateway.Adapters.MC4/Mc4Adapter.cs`
### 3.2 新增方法
```csharp
/// <summary>批量获取多个设备的实时点位值MC4.0 原生 multi/value/get</summary>
public async Task<Dictionary<int, List<Mc4PointValue>>> GetMultiRealtimeValuesAsync(List<int> deviceIds)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
var body = JsonSerializer.Serialize(new { ids = deviceIds });
var resp = await client.PostAsync("/api/central/point/multi/value/get",
new StringContent(body, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<Dictionary<int, List<Mc4PointValue>>>(json)!;
return result;
}
```
### 3.3 编译验证
`dotnet build` → 0 错误。
> **M2 提交点**: `Fix-M2: MC4 批量点位查询 GetMultiRealtimeValuesAsync`
---
## 4. 步骤 M3: 历史告警查询(预计 30min
### 4.1 新增 DTO
```csharp
/// <summary>MC4.0 历史告警查询请求</summary>
public class Mc4HisAlarmQuery
{
public string From { get; set; } = "";
public string To { get; set; } = "";
public int Skip { get; set; }
public int Limit { get; set; }
public int Sort { get; set; } = 1;
}
```
### 4.2 新增方法
```csharp
/// <summary>查询 MC4.0 历史告警(已恢复的告警)</summary>
public async Task<PagedResult<StandardAlarm>> GetHisAlarmsAsync(int page, int size, DateTime from, DateTime to)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
var body = JsonSerializer.Serialize(new Mc4HisAlarmQuery
{
From = from.ToString("yyyy-MM-dd HH:mm:ss"),
To = to.ToString("yyyy-MM-dd HH:mm:ss"),
Skip = (page - 1) * size,
Limit = size,
Sort = 1
});
var resp = await client.PostAsync("/api/central/his_alarm/query",
new StringContent(body, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<Mc4AlarmQueryResult>(json)!;
return new PagedResult<StandardAlarm>
{
Items = (result.List ?? new()).Select(MapAlarmItem).ToList(),
Total = result.Total
};
}
private StandardAlarm MapAlarmItem(Mc4AlarmItem a) => new()
{
AlarmId = a.Id ?? "",
AdapterCode = AdapterCode,
Level = MapAlarmLevel(a.Level),
Title = a.Desc ?? "",
OccurTime = DateTime.TryParse(a.Stime, out var st) ? st : DateTime.MinValue,
Status = MapAlarmState(a.State),
ActualValue = a.Soption?.Value,
ThresholdValue = a.Eoption?.Value
};
```
### 4.3 编译验证
`dotnet build` → 0 错误。
> **M3 提交点**: `Fix-M3: MC4 历史告警查询 GetHisAlarmsAsync`
---
## 5. 步骤 M4: B4-batch 路由优化(预计 15min
### 5.1 修改
`gateway/src/IntegrationGateway.Host/Program.cs` B4-batch 路由改用 MC4 原生批量接口:
```csharp
// B4-batch 改用 MC4 原生 multi/value/get
app.MapPost("/api/gateway/realtime/{adapter}/batch", async (string adapter, BatchRealtimeRequest req) =>
{
var a = registry.FindByCode<IHasPoints>(adapter);
if (a == null) return Results.NotFound();
if (a is Mc4Adapter mc4 && req.DeviceIds?.Count > 0)
{
// MC4.0 原生批量接口
var intIds = req.DeviceIds.Select(int.Parse).ToList();
var multi = await mc4.GetMultiRealtimeValuesAsync(intIds);
return Results.Ok(multi);
}
// 其他适配器 fallback
var results = new Dictionary<string, List<PointValue>>();
foreach (var id in req.DeviceIds ?? new())
try { results[id] = await a.GetRealtimeValuesAsync(id); } catch { }
return Results.Ok(results);
});
```
### 5.2 编译验证
`dotnet build` → 0 错误。
> **M4 提交点**: `Fix-M4: B4-batch 优化 MC4原生批量接口`
---
## 6. 步骤 M5: 编译验证 + 联调
- [ ] `dotnet build gateway/IntegrationGateway.slnx` → 0 错误 0 警告
- [ ] MC4 appsettings.json 填入真实 `Username/Password`
- [ ] 网关启动 → A1 注册 → A3 同步 MC4 设备树
- [ ] B4-batch 调 `multi/value/get` 返回批量值
- [ ] 告警查询 `/alarms/MC4:31ku` 有数据
- [ ] Mc4AuthHelper Token 非空 → 登录流程正常
> **M5 提交点**: `Fix-M5: MC4整改全量编译验证通过`
---
## 7. 改动文件汇总
| 步骤 | 文件 | 改动 |
|:---:|------|------|
| M1 | `Mc4AuthHelper.cs` | 重写认证流程: conf/get → login |
| M1 | `Mc4Adapter.cs` | 构造函数加 account/password |
| M1 | `Program.cs` | Mc4Adapter 构造传 Username/Password |
| M1 | `appsettings.json` | MC4 数组加 Username/Password |
| M2 | `Mc4Adapter.cs` | 新增 GetMultiRealtimeValuesAsync |
| M3 | `Mc4Adapter.cs` | 新增 GetHisAlarmsAsync + DTO |
| M4 | `Program.cs` | B4-batch 优化 MC4 原生批量 |

View File

@@ -0,0 +1,131 @@
# 网关 MC4 模块检查报告 2026-06-03
> **基准文档**: `doc/对接文档/MC4.0对外API.md` (31 API)
> **检查范围**: `gateway/src/IntegrationGateway.Adapters.MC4/` (Mc4Adapter.cs, Mc4AuthHelper.cs)
> **日期**: 2026-06-03
---
## 1. 覆盖率概览
MC4.0 接口文档共 **31 个 REST 端点**,当前 Mc4Adapter 覆盖了 **6 个**19%)。
| 模块 | 文档端点数 | 已实现 | 缺失 |
|------|:---:|:---:|:---:|
| 认证 | 3 | 0 | 3 |
| 对象树 | 1 | 1 | 0 |
| 点位 | 3 | 2 | 1 |
| 告警 | 14 | 3 | 11 |
| 系统管理 | 10 | 0 | 10 |
| **合计** | **31** | **6** | **25** |
---
## 2. 已实现接口对照
| MC4.0 端点 | Mc4Adapter 方法 | 能力接口 | 状态 |
|------|------|------|:--:|
| /api/central/object/tree | GetObjectTreeAsync | IHasOwnDeviceTree | ✅ |
| /api/central/device/point/value/get | GetRealtimeValuesAsync | IHasPoints | ✅ |
| /api/central/point/value/set | SetPointValueAsync | IHasPoints | ✅ |
| /api/central/alarm/query | GetAlarmsAsync | IHasAlarms | ✅ |
| /api/central/alarm/confirm | ConfirmAlarmAsync | IHasAlarms | ✅ |
| /api/central/alarm/end | EndAlarmAsync | IHasAlarms | ✅ |
---
## 3. 🔴 关键问题
### 3.1 Mc4AuthHelper 认证逻辑错误(🔥 致命)
**现状**: `GetTokenAsync` 调用 `/api/central/auth/conf/get`
```csharp
var resp = await _http.PostAsync($"{_baseUrl}/api/central/auth/conf/get", null);
var result = JsonSerializer.Deserialize<Mc4AuthResponse>(json);
_token = result?.Token ?? "";
```
**错误**: `/api/central/auth/conf/get` 是**密码加密配置查询接口**,返回 `{ "encrypt": true/false }`**不是 Token 接口**,不包含 `token` 字段。`result?.Token` 始终为 null`_token` 被设为空字符串。
**实际登录接口**: `/api/central/auth/login`
```json
POST /api/central/auth/login
{ "account": "admin", "password": "xxx" }
{ "token": "string", "id": 0, "account": "string", "name": "string" }
```
> **注意**: MC4.0 可能对大部分 API 不强制 Token 认证curl 示例中只有 logout 接口显式传了 header。但当前代码逻辑错误即便需要 Token 也无法获取。
**修复**: Mc4AuthHelper 改为先调 `conf/get` 确认加密方式,再用 `account/password``login` 获取真正的 token。
### 3.2 缺少批量点位查询(🟠 规则引擎依赖)
**缺失**: `/api/central/point/multi/value/get`
请求体 `{ "ids": [1, 2, 3] }` → 一次返回多个设备的实时值。
**影响**: 当前 B4-batch 接口逐设备调 `GetRealtimeValuesAsync`单设备接口。MC4.0 提供原生批量接口,应直接使用以提升规则引擎性能。
**修复**: 增加 `GetMultiRealtimeValuesAsync(List<int> deviceIds)` 方法B4-batch 路由优先调此方法。
---
## 4. 缺失项清单
### 4.1 认证接口3个
| 端点 | 用途 |
|------|------|
| `/api/central/auth/conf/get` | 获取密码加密配置(已调但未正确使用) |
| `/api/central/auth/login` | 登录获取 Token |
| `/api/central/auth/logout` | 注销 |
### 4.2 设备点位1个
| 端点 | 用途 |
|------|------|
| `/api/central/device/point/get` | 查询设备的点位列表(用于发现设备有哪些测点) |
### 4.3 告警扩展11个
| 端点 | 用途 |
|------|------|
| `/api/central/alarm/custom_query_count` | 告警自定义统计数量 |
| `/api/central/alarm/custom_query` | 告警自定义查询 |
| `/api/central/alarm/get_by_point` | 按点位查询告警 |
| `/api/central/alarm/get` | 获取单个告警详情 |
| `/api/central/his_alarm/query` | 历史告警查询 |
| `/api/central/report/alarm/convergence/query` | 告警聚合报告查询 |
| `/api/central/alarm/type/add` | 添加告警类型 |
| `/api/central/alarm/type/set` | 修改告警类型 |
| `/api/central/alarm/type/del` | 删除告警类型 |
| `/api/central/alarm/type/list` | 告警类型列表 |
### 4.4 系统管理10个
| 端点 | 用途 |
|------|------|
| `/api/central/manager/config/set` | 设置系统配置 |
| `/api/central/manager/config/get` | 获取系统配置 |
| `/api/central/manager/db/backup` | 数据库备份 |
| `/api/central/manager/db/restore` | 数据库恢复 |
| `/api/central/manager/db/log` | 数据库日志 |
| `/api/central/manager/hisdb/backup` | 历史库备份 |
| `/api/central/manager/hisdb/restore` | 历史库恢复 |
| `/api/central/manager/hisdb/clear` | 清除历史数据 |
| `/api/central/manager/picture/clear` | 清除图片 |
| `/api/central/manager/video/clear` | 清除视频 |
---
## 5. 优先级建议
| 优先级 | 项目 | 说明 |
|:---:|------|------|
| 🔴 P0 | Mc4AuthHelper 认证修复 | 当前 Token 获取逻辑根本错误 |
| 🟠 P1 | 批量点位查询 (multi/value/get) | 规则引擎 B4-batch 缺少原生高效接口 |
| 🟡 P2 | 历史告警查询 | 管理端需要查看已结束的告警 |
| 🟡 P2 | 设备点位发现 (device/point/get) | IoT 设备入网时自动发现测点 |
| ⚪ P3 | 告警类型 CRUD | 运维操作 |
| ⚪ P3 | 系统管理接口 | 运维操作 |

View File

@@ -0,0 +1,423 @@
# 网关 Owl 模块整改方案 v1.0
> **版本**: 1.0
> **日期**: 2026-06-03
> **基准**: `doc/设计文档/网关owl模块检查报告20260603.md`
> **架构原则**: 遵循网关设计原则 §3.2-3.4(显式、异步、统一分页、弹性 Extra、不修改已有接口签名
---
## 1. 整改总览
| 阶段 | 优先级 | 内容 | 涉及文件 | 预计 |
|:---:|:---:|------|------|:---:|
| O1 | P0 | 设备通道展开 + OwlDevice 模型补全 | OwlAdapter.cs + OwlModels.cs | 2h |
| O2 | P0 | AI 事件接入 IHasAlarms | OwlAdapter.cs + OwlModels.cs | 2h |
| O3 | P1 | 回放取流修正 + PTZ 预设位 | OwlAdapter.cs | 1h |
| O4 | P2 | AI 检测启停(IAcceptsControl) | OwlAdapter.cs | 1h |
| O5 | P2 | 推流/拉流管理(可选独立路由) | Program.cs + OwlAdapter | 1.5h |
| O6 | 验证 | 全量编译 + 联调 | — | 1h |
| **合计** | — | — | **5 文件** | **~8.5h** |
---
## 2. 阶段 O1: 设备通道展开 + 模型补全(预计 2h
### 2.1 现状与问题
```csharp
// 当前: GET /devices → 只返回NVR父设备
public async Task<PagedResult<StandardDevice>> GetDevicesAsync(...)
{
var json = await client.GetStringAsync($"/devices?page={page}&size={size}");
// MapDevice: IsParent=true, Category="硬盘录像机" — 无通道子设备
}
```
**后果**: Vol.Pro 设备列表只有 NVR前端"预览"按钮找不到摄像头通道。
### 2.2 整改设计
**改用** `GET /devices/channels` — Owl 的联合接口直接返回设备+通道的扁平列表:
```json
{
"items": [
{ "id": "mp123", "type": "DEVICE", "name": "NVR-01", "is_online": "1", "channel_count": 4, ... },
{ "id": "mp123/34020000001320000001", "type": "CHANNEL", "did": "mp123", "name": "仓库入口", "is_online": true, "ptztype": 1, ... },
{ "id": "mp123/34020000001320000002", "type": "CHANNEL", "did": "mp123", "name": "仓库后门", "is_online": true, "ptztype": 0, ... }
],
"total": 3
}
```
**映射逻辑**(单次请求完成父子映射):
```csharp
// OwlDeviceChannel 联合模型
public class OwlDeviceChannel
{
public string? Id { get; set; }
public string? Type { get; set; } // "DEVICE" | "CHANNEL"
public string? Did { get; set; } // 通道所属设备ID
public string? Name { get; set; }
public string? IsOnline { get; set; } // DEVICE: "1"/"0", CHANNEL: true/false
public string? Manufacturer { get; set; }
public string? Model { get; set; }
public string? Firmware { get; set; }
public string? Longitude { get; set; }
public string? Latitude { get; set; }
public int? ChannelCount { get; set; }
public int? Ptztype { get; set; } // CHANNEL: 0=无云台, 1=方向, 2=预置位
public string? App { get; set; } // CHANNEL: 流应用名
public string? StreamId { get; set; } // CHANNEL: 流ID
// ... 其他字段
}
```
**GetDevicesAsync 重写**
```csharp
public async Task<PagedResult<StandardDevice>> GetDevicesAsync(int page, int size, string? keyword = null)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
var url = $"/devices/channels?page={page}&size=1000"; // 大pageSize一次性获取
if (!string.IsNullOrEmpty(keyword)) url += $"&key={Uri.EscapeDataString(keyword)}";
var json = await client.GetStringAsync(url);
var result = JsonSerializer.Deserialize<OwlPagedResult<OwlDeviceChannel>>(json)!;
var devices = new List<StandardDevice>();
// 第一遍: 映射 DEVICE 为父设备
var deviceItems = result.Items.Where(x => x.Type == "DEVICE").ToList();
var channelItems = result.Items.Where(x => x.Type == "CHANNEL").ToList();
foreach (var d in deviceItems)
{
// 收集该设备的通道
var childChannels = channelItems.Where(c => c.Did == d.Id).ToList();
devices.Add(new StandardDevice
{
SourceId = d.Id ?? "",
Name = d.Name ?? d.Id ?? "",
Category = "硬盘录像机",
Group = "视频设备",
IsOnline = d.IsOnline == "1",
IsParent = true,
IpAddress = d.Address,
Extra = new Dictionary<string, object?>
{
["manufacturer"] = d.Manufacturer,
["model"] = d.Model,
["firmware"] = d.Firmware,
["longitude"] = d.Longitude,
["latitude"] = d.Latitude,
["channelCount"] = d.ChannelCount ?? childChannels.Count
}
});
// 映射通道为子设备
foreach (var ch in childChannels)
{
devices.Add(new StandardDevice
{
SourceId = ch.Id ?? "",
Name = ch.Name ?? $"通道{ch.Id}",
Category = "摄像机",
Group = "视频设备",
IsOnline = ch.IsOnline?.ToLower() == "true" || ch.IsOnline == "1",
IsParent = false,
ParentSourceId = d.Id,
Extra = new Dictionary<string, object?>
{
["hasPtz"] = (ch.Ptztype ?? 0) > 0 ? "1" : "0",
["app"] = ch.App,
["streamId"] = ch.StreamId
}
});
}
}
return new PagedResult<StandardDevice> { Items = devices, Total = devices.Count };
}
```
### 2.3 影响分析
| 影响点 | 说明 |
|------|------|
| 前端预览按钮 | 现在能找到 `DeviceGroup=视频设备, IsParent=否` 的通道子设备,预览按钮可用 |
| 设备树同步 | A3 同步时有父子关系,`ParentSourceId` 解析为父设备 DeviceId |
| 视频墙 | 摄像机通道列表包含 `hasPtz` 标识,云台面板按需显示 |
| MC4/IoT | 零影响 — 不同适配器独立运行 |
---
## 3. 阶段 O2: AI 事件接入 IHasAlarms预计 2h
### 3.1 现状态
`OwlAdapter` **没有**实现 `IHasAlarms`AI 事件走不到 Vol.Pro。
### 3.2 整改设计
**OwlAdapter 增加 IHasAlarms 实现**
```csharp
public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAcceptsMetadataPush, IHasAlarms
{
// Capabilities 增加 HasAlarms = true
/// <summary>GET /events → StandardAlarm[]</summary>
public async Task<PagedResult<StandardAlarm>> GetAlarmsAsync(
int page, int size, DateTime from, DateTime to, string? level = null, string? state = null)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
var fromMs = new DateTimeOffset(from).ToUnixTimeMilliseconds();
var toMs = new DateTimeOffset(to).ToUnixTimeMilliseconds();
var url = $"/events?page={page}&size={size}&start_ms={fromMs}&end_ms={toMs}";
var json = await client.GetStringAsync(url);
var result = JsonSerializer.Deserialize<OwlPagedResult<OwlAiEvent>>(json)!;
return new PagedResult<StandardAlarm>
{
Items = result.Items.Select(MapEventToAlarm).ToList(),
Total = result.Total
};
}
private StandardAlarm MapEventToAlarm(OwlAiEvent e) => new()
{
AlarmId = $"owl-ai-{e.Id}",
AdapterCode = AdapterCode,
Level = e.Label switch {
"person" => "重要",
"car" => "重要",
_ => "普通"
},
Title = $"AI检测: {e.Label} (置信度 {e.Score:P0})",
Content = $"通道{e.Cid}: {e.Zones ?? ""}",
OccurTime = DateTimeOffset.FromUnixTimeMilliseconds(e.StartedAt ?? 0).DateTime,
Status = e.EndedAt > 0 ? "已结束" : "未确认",
Extra = new Dictionary<string, object?>
{
["imagePath"] = e.ImagePath,
["score"] = e.Score,
["label"] = e.Label,
["model"] = e.Model
}
};
public async Task ConfirmAlarmAsync(string alarmId) { /* AI事件不支持确认 */ }
public async Task EndAlarmAsync(string alarmId) { /* AI事件不支持结束 */ }
}
```
### 3.3 事件快照图片
网关注册一条 B-路由直接代理图片访问:
```csharp
// 在 OwlAdapter 中增加
public async Task<byte[]> GetEventImageAsync(string imagePath)
{
var client = await _auth.GetAuthenticatedClientAsync();
return await client.GetByteArrayAsync($"/events/image/{imagePath}");
}
// Program.cs 加路由
app.MapGet("/api/gateway/owl/image/{*path}", async (string path, AdapterRegistry registry) =>
{
var owl = registry.FindByCode<OwlAdapter>("Owl:main");
if (owl == null) return Results.NotFound();
var bytes = await owl.GetEventImageAsync(path);
return Results.File(bytes, "image/jpeg");
});
```
### 3.4 后端 DTO 补充
```csharp
/// <summary>Owl AI 事件</summary>
public class OwlAiEvent
{
public long? Id { get; set; }
public string? Did { get; set; } // 设备ID
public string? Cid { get; set; } // 通道ID
public long? StartedAt { get; set; } // 毫秒时间戳
public long? EndedAt { get; set; }
public string? Label { get; set; } // person / car / ...
public float? Score { get; set; } // 0.0-1.0
public string? Zones { get; set; } // 检测区域JSON
public string? ImagePath { get; set; }
public string? Model { get; set; }
}
```
---
## 4. 阶段 O3: 回放修正 + PTZ 扩展(预计 1h
### 4.1 GetPlaybackUrlAsync 修正
当前手工拼 URL改为调用 Owl API
GoWVP 文档中播放接口 POST /channels/{id}/play 返回的 `PlayOutput.Items[]` 包含 `Hls` 字段。录像回放无需额外接口——同一个 HLS 地址加上 `start_ms`/`end_ms` 参数即可。
**方案**: 保持当前实现(手工拼 URL 是 Owl 的约定用法),增加 URL 不存在时的 fallback
```csharp
public async Task<StreamUrls> GetPlaybackUrlAsync(string channelId, DateTime start, DateTime end)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
var token = await _auth.GetTokenAsync();
var startMs = new DateTimeOffset(start).ToUnixTimeMilliseconds();
var endMs = new DateTimeOffset(end).ToUnixTimeMilliseconds();
var baseAddr = client.BaseAddress?.ToString().TrimEnd('/') ?? "";
return new StreamUrls
{
Hls = $"{baseAddr}/recordings/channels/{channelId}/index.m3u8?start_ms={startMs}&end_ms={endMs}&token={token}"
};
}
```
变化:`client.BaseAddress` → 实际 Owl 地址(之前隐式依赖 `HttpClient.BaseAddress` 已包含)。
### 4.2 PTZ 预设位/巡航
`PtzControlAsync` 增加 action 参数透传:
```csharp
public async Task PtzPresetAsync(string channelId, int presetIndex)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
await client.PostAsJsonAsync($"/channels/{channelId}/ptz/control",
new { action = "preset", preset = presetIndex });
}
```
无需修改 `IHasStreams` 接口——PTZ 扩展通过 `PtzControlAsync(direction: "preset_1")` 或新增公开方法由 B-路由直接调用。
---
## 5. 阶段 O4: AI 检测启停(预计 1h
### 5.1 通过 IAcceptsControl 暴露
`OwlAdapter` 实现 `IAcceptsControl`(已在 KMS 适配器中新增的接口):
```csharp
public class OwlAdapter : ..., IAcceptsControl
{
public async Task<ControlResult> SendControlAsync(string sourceDeviceId, string command, Dictionary<string, object?> parameters)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
try
{
switch (command)
{
case "ai-enable":
await client.PostAsync($"/channels/{sourceDeviceId}/ai/enable", null);
break;
case "ai-disable":
await client.PostAsync($"/channels/{sourceDeviceId}/ai/disable", null);
break;
case "zone-add":
await client.PostAsJsonAsync($"/channels/{sourceDeviceId}/zones", parameters!);
break;
default:
return new ControlResult { Success = false, Message = $"不支持的指令: {command}" };
}
return new ControlResult { Success = true };
}
catch (Exception ex) { return new ControlResult { Success = false, Message = ex.Message }; }
}
}
```
前端调用:`POST /api/gateway/control/Owl:main { deviceId: "ch123", command: "ai-enable" }`
---
## 6. 阶段 O5: 推流/拉流管理(可选项,预计 1.5h
### 6.1 设计决策
推流/拉流管理属于**管理员操作**而非实时数据查询。建议通过 B-组新路由暴露,不新增 Core 接口:
```csharp
// Program.cs — 推流/拉流 CRUD 路由组
app.MapGet("/api/gateway/owl/stream-pushs", async (int page, int size, AdapterRegistry registry) => { ... });
app.MapPost("/api/gateway/owl/stream-pushs", async (StreamPushRequest req, ...) => { ... });
// ... 类推
```
### 6.2 推流请求模型
```csharp
public class StreamPushRequest
{
public string Name { get; set; } = "";
public string App { get; set; } = "live";
public string Stream { get; set; } = "";
public bool? IsAuthEnabled { get; set; }
}
public class StreamProxyRequest
{
public string Name { get; set; } = "";
public string Type { get; set; } = "RTSP";
public string App { get; set; } = "live";
public string Stream { get; set; } = "";
public string? SourceUrl { get; set; }
public int? Transport { get; set; }
}
```
### 6.3 作用
管理端通过网关统一管理 Owl 视频源添加/删除/状态查询,无需单独登录 Owl 控制台。前端可加"添加摄像头"按钮调用这些路由。
---
## 7. 文件变更清单
| 文件 | 新增 | 修改 | 说明 |
|------|:---:|:---:|------|
| `OwlAdapter.cs` | — | ✅ | GetDevicesAsync 重写 + IHasAlarms 实现 + IAcceptsControl 实现 + PTZ 预设位 |
| `OwlModels.cs` (新建) | ✅ | — | OwlDeviceChannel + OwlAiEvent + DTO 完整化(从 OwlAdapter.cs 分离) |
| `OwlAuthHelper.cs` | — | ✅ | HealthCheck 端点改 /stats需确认 |
| `Program.cs` | ✅ | ✅ | AI 事件图片代理路由 + 推流/拉流路由O5 |
| `IAcceptsControl.cs` | — | — | 已存在KMS 阶段新增) |
---
## 8. 与现有架构的兼容性
| 架构元素 | 影响 |
|------|------|
| IHasFlatDevices 签名 | 不变 — GetDevicesAsync 签名不变,仅内部实现改为调 /devices/channels |
| IHasAlarms | OwlAdapter 新增实现,零冲突 — KMS 也实现了 IHasAlarms |
| IAcceptsControl | OwlAdapter 新增实现 — KMS 已有实现B10 路由自动发现 |
| AdapterCapabilities | 扩展 HasAlarms=true, FeatureFlags["aiEnable"]=true |
| Vol.Pro A3 同步 | ParentSourceId 已有解析逻辑,新通道子设备自然被正确处理 |
| 前端 base_device.vue | 无改动 — 操作列按钮按 DeviceGroup="视频设备" 自动匹配新展开的通道 |
---
## 9. 验证点
| 场景 | 预期 |
|------|------|
| GET /api/gateway/devices?adapter=Owl:main | 返回 NVR 父设备 + 通道子设备,子设备有 hasPtz Extra |
| Vol.Pro 设备列表 | 显示 Owl 摄像机通道AdapterCode=Owl:main |
| 前端预览按钮 | 通道子设备显示"预览"按钮,点击播放实时流 |
| GET /api/gateway/alarms/Owl:main | 返回 AI 检测事件(人员/车辆等) |
| 规则引擎 | 可将 Owl AI 事件作为告警源触发规则 |
| POST /api/gateway/control/Owl:main ai-enable | 远程开启 Owl AI 检测 |

View File

@@ -0,0 +1,197 @@
# 网关 Owl 模块检查报告 2026-06-03
> **基准文档**: `doc/对接文档/GoWVP接口文档.md` (3419行, ~40个API)
> **检查范围**: `gateway/src/IntegrationGateway.Adapters.Owl/` (OwlAdapter.cs, OwlAuthHelper.cs)
> **日期**: 2026-06-03
---
## 1. 覆盖率概览
GoWVP 接口文档共 **40 个 REST 端点**,当前 OwlAdapter 覆盖了 **8 个**20%)。
| 模块 | 文档端点数 | 已实现 | 缺失 |
|------|:---:|:---:|:---:|
| 控制台 | 1 | 0 | 1 |
| 推流列表 | 4 | 0 | 4 |
| 拉流代理 | 4 | 0 | 4 |
| 国标设备 | 6 | 3 | 3 |
| 国标通道 | 3 | 3 | 0 |
| 通道管理 | 4 | 2 | 2 |
| AI 检测 | 4 | 0 | 4 |
| 事件 | 5 | 0 | 5 |
| 区域管理 | 2 | 0 | 2 |
| 配置管理 | 2 | 0 | 2 |
| 流媒体 | 2 | 0 | 2 |
| ONVIF | 3 | 0 | 3 |
| **合计** | **40** | **8** | **32** |
---
## 2. 已实现接口对照
| GoWVP 端点 | OwlAdapter 方法 | 能力接口 | 状态 |
|------|------|------|:--:|
| GET /devices | GetDevicesAsync | IHasFlatDevices | ✅ |
| PUT /devices/{id} | PushMetadataAsync | IAcceptsMetadataPush | ✅ |
| POST /channels/{id}/play | GetLiveUrlAsync | IHasStreams | ✅ |
| POST /channels/{id}/ptz | PtzControlAsync / PtzStopAsync | IHasStreams | ✅ |
| POST /channels/{id}/snapshot | GetSnapshotAsync | IHasStreams | ✅ |
| GET /recordings | GetRecordingsAsync | IHasRecordings | ✅ |
| (未映射) | GetPlaybackUrlAsync | IHasStreams | ⚠️ 自拼URL |
| GET /health | HealthCheckAsync | IGatewayAdapter | ✅ |
---
## 3. 缺失项详细清单
### 3.1 控制台监控1个
| 端点 | 用途 | 影响 |
|------|------|------|
| **GET /stats** | CPU/内存/磁盘/网络实时监控 | 管理端无法查看 Owl 服务器健康度 |
**建议**: OwlAdapter 增加 `GetStatsAsync()`,返回 CPU/内存/磁盘 JSON`Capabilities.HasStats = true`
### 3.2 推流管理4个
| 端点 | 说明 |
|------|------|
| POST /stream_pushs | 添加推流通道 |
| GET /stream_pushs | 分页查询推流列表 |
| PUT /stream_pushs/{id} | 编辑推流 |
| DELETE /stream_pushs/{id} | 删除推流 |
**影响**: 管理端无法从 Vol.Pro 直接添加/管理 Owl 推流通道,需登录 Owl 控制台操作。
### 3.3 拉流代理4个
| 端点 | 说明 |
|------|------|
| POST /stream_proxys | 添加拉流代理 |
| GET /stream_proxys | 分页查询拉流列表 |
| PUT /stream_proxys | 编辑拉流代理 |
| DELETE /stream_proxys/{id} | 删除拉流代理 |
**影响**: 同推流,非 GB28181 的 RTSP/RTMP 通道无法通过管理端管理。
### 3.4 国标设备扩展3个
| 端点 | 说明 | 当前替代 |
|------|------|------|
| **GET /devices/channels** | 一键获取所有设备+通道列表 | 无 — 当前 GetDevicesAsync 只返回 NVR不展开通道 |
| POST /devices | 添加 GB28181 设备 | 无 |
| POST /devices/{id}/catalog | 查询设备目录 | 无 |
**关键缺失**: `GET /devices/channels` 直接返回设备+通道的联合结果,比单独调 `/devices` + `/channels` 高效。当前适配器在 `GetDevicesAsync` 中只映射了 NVR 设备IsParent=true**没有展开下级通道**。
### 3.5 通道管理2个
| 端点 | 说明 |
|------|------|
| **POST /channels** | 添加 RTMP/RTSP 通道 |
| **DELETE /channels/{id}** | 删除通道 |
| GET /channels | 通道列表(独立) |
**半缺失**: `GET /channels``PUT /channels/{id}` 虽未直接调用,但流和 PTZ 接口已间接使用通道 ID。
### 3.6 AI 检测能力4个
| 端点 | 说明 |
|------|------|
| POST /channels/{id}/ai/enable | 启用 AI 检测 |
| POST /channels/{id}/ai/disable | 禁用 AI 检测 |
| POST /channels/{id}/zones | 添加 AI 检测区域 |
| GET /channels/{id}/zones | 获取检测区域 |
**影响**: Owl 的 AI 人数统计/区域入侵能力无法通过网关管理端开启/配置。
### 3.7 AI 事件管理5个
| 端点 | 说明 |
|------|------|
| GET /events | 分页查询 AI 事件(按通道/标签/时间筛选) |
| GET /events/{id} | 事件详情 |
| PUT /events/{id} | 更新事件 |
| DELETE /events/{id} | 删除事件 |
| GET /events/image/{path} | 获取事件快照图片 |
**战略缺失**: Owl AI 事件(人员检测、车辆检测、入侵告警等)是**视频智能监控的核心数据**。当前网关零接入,意味着:
- 规则引擎无法以"人数越限"为条件触发动作
- AI 事件不能同步到 Vol.Pro 告警表
- 事件快照无法在大屏展示
**建议**: 通过 `IHasAlarms` 接口暴露 AI 事件,映射 `StandardAlarm { AlarmId=event.id, Level=重要/普通, Title=label, Content=快照路径 }`
### 3.8 系统管理4个
| 端点 | 说明 |
|------|------|
| GET /configs/info | 查询配置 |
| PUT /configs/info/sip | 修改 SIP 配置 |
| GET /media_servers | 流媒体列表 |
| PUT /media_servers/{id} | 修改流媒体 |
**影响**: 运维类接口,暂不阻塞业务。
### 3.9 ONVIF3个
| 端点 | 说明 |
|------|------|
| GET /onvif/devices-discover | ONVIF 设备发现 |
| POST /onvif | 添加 ONVIF 设备 |
| GET /onvif/discover | ONVIF 设备发现(SSE) |
---
## 4. 现有代码问题项
### 4.1 设备列表只返回 NVR 不展开通道
`GetDevicesAsync``MapDevice` 将所有设备映射为 `IsParent=true, Category="硬盘录像机"`,不查询也不返回通道子设备。这导致:
- Vol.Pro 设备树中 Owl 设备全是父设备,无摄像头子节点
- 前端预览按钮要求 `DeviceGroup='视频设备'` 的叶子设备,找不到子设备
`GET /devices/channels` 可一次性返回设备+通道,相比两次调用更高效。
### 4.2 OwlDevice 模型字段不完整
当前 `OwlDevice` 只有 8 个字段Id, Name, IsOnline, Protocol, Address, Port, TransportGoWVP 返回的 Device 至少有 20+ 个字段,缺失包括:
| 缺失字段 | 说明 | 用途 |
|------|------|------|
| Manufacturer | 厂商 | 设备详情显示 |
| Model | 型号 | 设备详情显示 |
| Firmware | 固件版本 | 运维 |
| Longitude/Latitude | 经纬度 | 地图标记 |
| ChannelCount | 通道数 | 统计 |
| Status/RegisterWay | 注册方式 | GB28181 状态 |
| CreatedAt/UpdatedAt | 时间戳 | 同步管理 |
### 4.3 HealthCheck 端点路径可能错误
代码调 `GET /health`,但 GoWVP 文档显示控制台唯一端点 `GET /stats`。需确认 Owl 实际实现。
### 4.4 GetPlaybackUrlAsync 手工拼 URL
直接拼接 `/recordings/channels/{id}/index.m3u8?start_ms=&end_ms=&token=`,未调用 Owl API。虽然功能通常可用但依赖内部路径约定Owl 版本升级可能失效。
### 4.5 无 PTZ 预设位/巡航支持
GoWVP PTZ 接口 `POST /channels/{id}/ptz/control` 支持 `action: preset/patrol/scan/stop`,当前仅实现 `continuous` 方向移动和 `stop`
---
## 5. 优先级建议
| 优先级 | 项目 | 说明 |
|:---:|------|------|
| 🔴 P0 | 设备列表展开通道 | 前端无法展示摄像头设备 |
| 🔴 P0 | AI 事件接入 IHasAlarms | 规则引擎无法获知人数/入侵 |
| 🟠 P1 | 使用 GET /devices/channels | 替代当前单独调 /devices |
| 🟠 P1 | OwlDevice 字段补全 | 设备详情展示 |
| 🟡 P2 | AI 检测启停 | 远程控制 Owl AI |
| 🟡 P2 | 推流/拉流 CRUD | 管理端统一通道管理 |
| ⚪ P3 | 预设位/巡航 PTZ | 高级云台功能 |
| ⚪ P3 | 系统管理接口 | 运维便捷性 |
| ⚪ P3 | ONVIF 设备发现 | 部署时的设备发现 |

View File

@@ -0,0 +1,97 @@
# 网关自动注册机制整改 — 任务清单
> **版本**: 1.0
> **日期**: 2026-06-03
> **基准**: `doc/设计文档/网关自动注册机制整改方案_v1.0.md` + `doc/设计文档/网关自动注册机制检查报告20260603.md`
> **原则**: 分阶段分步骤执行,每步骤完成编译复查后提交,不合并主分支
---
## 阶段 G1: Gateway 端修复3 步骤)
### 步骤 G1.1 — 修复 BaseUrl 硬编码
- [ ] 编辑 `gateway/src/IntegrationGateway.Host/Program.cs`
- [ ]`BaseUrl = $"http://localhost:..."` 改为读取 `gwCfg["SelfUrl"]`,不填降级 localhost
- [ ] 编辑 `gateway/src/IntegrationGateway.Host/appsettings.json`Gateway 段新增 `"SelfUrl": null`
- [ ] `dotnet build gateway/IntegrationGateway.slnx` → 0 错误
- [ ] 复查:`BaseUrl` 不再硬编码 localhost可从配置注入真实 IP
> **G1.1 提交点**: `Fix-G1.1: Gateway A1 BaseUrl 改为读取 SelfUrl 配置`
### 步骤 G1.2 — A1 注册后追加 A3 设备同步
- [ ] 编辑 `gateway/src/IntegrationGateway.Host/Program.cs`
- [ ] A1 注册成功后:遍历 `registry.All``IHasFlatDevices``GetDevicesAsync``IHasOwnDeviceTree``GetObjectTreeAsync` + 展平
- [ ] 新增 `FlattenTree` 辅助函数MC4 对象树展平)
- [ ]`clientFactory.SyncDevicesAsync(nodeCode, nodeToken, allDevices)`
- [ ] `dotnet build` → 0 错误
- [ ] 复查A1 成功后立即执行 A3注册完成时 Vol.Pro 已有设备数据
> **G1.2 提交点**: `Fix-G1.2: A1注册后立即A3同步全部适配器设备列表`
### 步骤 G1.3 — 启动 A2 心跳 + 自动重注册
- [ ] 编辑 `gateway/src/IntegrationGateway.Host/Program.cs`
- [ ] A1/A3 完成后启动 `Task.Run` 心跳循环
- [ ] `PeriodicTimer` 每 15s → `clientFactory.HeartbeatAsync`
- [ ] 连续失败 ≥ 3 次 → 触发 A1+A3 重注册
- [ ] 新增 `SyncAllDevicesAsync` 辅助函数(复用 A3 逻辑)
- [ ] `dotnet build` → 0 错误
- [ ] 复查:心跳成功重置 `failCount`,失败累积到 3 次自动恢复
> **G1.3 提交点**: `Fix-G1.3: A2心跳+自动重注册(连续3次失败触发A1+A3)`
---
## 阶段 G2: Vol.Pro 端修复2 步骤)
### 步骤 G2.1 — RegisterNodeAsync 语法规范化
- [ ] 编辑 `api_sqlsugar/Warehouse/Services/device_manager/Partial/gateway_nodesService.cs`
- [ ] `RegisterNodeAsync``DbContext.Queryable.First()``FindAsIQueryable.FirstOrDefaultAsync()`
- [ ] `UpdateHeartbeatAsync`:同样替换
- [ ] `dotnet build api_sqlsugar/Warehouse` → 0 错误
- [ ] 复查:两个方法使用统一 Vol.Pro 查询语法,`.First()` 不抛异常
> **G2.1 提交点**: `Fix-G2.1: gateway_nodesService 统一 FindAsIQueryable 语法`
### 步骤 G2.2 — 标记 UpsertDeviceAsync 为废弃
- [ ] 编辑 `api_sqlsugar/Warehouse/Services/device_manager/Partial/base_deviceService.cs`
- [ ] `UpsertDeviceAsync``[Obsolete]` 标记 + 注释说明
- [ ] 检查接口文件 `Ibase_deviceService` 是否暴露此方法,同步标记
- [ ] `dotnet build` → 0 错误(允许 [Obsolete] 警告)
- [ ] 复查:重复逻辑已标记,新代码不会误用
> **G2.2 提交点**: `Fix-G2.2: base_deviceService.UpsertDeviceAsync 标记 [Obsolete]`
---
## 阶段 G3: 全量验证1 步骤)
### 步骤 G3.1 — 全量编译 + 联调场景验证
- [ ] `dotnet build gateway/IntegrationGateway.slnx` → 0 错误
- [ ] `dotnet build api_sqlsugar/VolPro.WebApi` → 0 错误
- [ ] 网关启动 → 控制台输出 A1 注册 → A3 同步 N 台 → A2 心跳启动
- [ ] `gateway_nodes` 表有记录,`LastHeartbeat` 持续更新
- [ ] `base_device` 表有对应设备
- [ ] 网关先启动Vol.Pro 未启动)→ 45 秒后自动恢复
- [ ] 复查:全链路 A1→A3→A2 闭环正常
> **G3.1 提交点**: `Fix-G3: 全量编译验证通过 注册机制闭环完成`
---
## 任务总览
| 阶段 | 步骤 | 文件 | 预计 |
|:---:|:---:|------|:---:|
| G1 | G1.1 BaseUrl 修复 | Program.cs + appsettings.json | 10min |
| G1 | G1.2 A3 设备同步 | Program.cs | 30min |
| G1 | G1.3 心跳+重注册 | Program.cs | 20min |
| G2 | G2.1 语法规范化 | gateway_nodesService.cs | 5min |
| G2 | G2.2 标记废弃方法 | base_deviceService.cs | 10min |
| G3 | G3.1 全量验证 | 全项目 | 15min |
| **合计** | **6 步骤** | **5 文件** | **~1.5h** |

View File

@@ -0,0 +1,336 @@
# 网关 ↔ Vol.Pro 自动注册机制整改方案 v1.0
> **版本**: 1.0
> **日期**: 2026-06-03
> **基准**: `doc/设计文档/网关自动注册机制检查报告20260603.md`
> **改动范围**: `gateway/Program.cs` + `VolPro/gateway_nodesService.cs` + `VolPro/base_deviceService.cs`
---
## 1. 整改步骤
### 步骤 1: 修复网关 A1 BaseUrl预计 10min
**文件**: `gateway/src/IntegrationGateway.Host/Program.cs`
**当前代码**line 100-101:
```csharp
BaseUrl = $"http://localhost:{app.Urls.FirstOrDefault()?.Split(':').LastOrDefault() ?? "5100"}"
```
**修改为**:
```csharp
// 优先读取 Gateway:SelfUrl 配置,不填时自动从 Urls 取端口
var port = app.Urls.FirstOrDefault()?.Split(':').LastOrDefault() ?? "5100";
var selfUrl = gwCfg["SelfUrl"] ?? $"http://localhost:{port}";
```
然后将 `BaseUrl =` 行改为:
```csharp
BaseUrl = selfUrl
```
**appsettings.json 补充**:
```json
"Gateway": {
"SelfUrl": null, // 生产环境填真实IP: http://192.168.1.100:5100, 留空则用 localhost
...
}
```
**编译验证**: `dotnet build gateway/IntegrationGateway.slnx` → 0 错误。
---
### 步骤 2: A1 注册后立即调用 A3 设备同步(预计 30min
**文件**: `gateway/src/IntegrationGateway.Host/Program.cs`
**在 A1 注册成功后追加 A3 同步**。当前代码line 97-105替换为:
```csharp
try
{
var registerReq = new GatewayRegisterRequest
{
NodeCode = nodeCode, Token = nodeToken,
AdapterTypes = adapterTypes, BaseUrl = selfUrl
};
await clientFactory.RegisterAsync(registerReq);
Console.WriteLine($"[Gateway] A1 注册完成: nodeCode={nodeCode}, adapters={adapterTypes}");
// ── A3: 同步所有适配器设备到 Vol.Pro ──
var allDevices = new List<object>();
foreach (var adapter in registry.All)
{
try
{
if (adapter is IHasFlatDevices flat)
{
var result = await flat.GetDevicesAsync(1, 1000);
foreach (var item in result.Items)
{
// 映射为 A3 接口期望的格式
allDevices.Add(new
{
AdapterCode = item.AdapterCode,
SourceId = item.SourceId,
Name = item.Name,
Category = item.Category,
Group = item.Group,
IsParent = item.IsParent,
ParentSourceId = item.ParentSourceId,
IsOnline = item.IsOnline,
IpAddress = item.IpAddress,
Port = item.Port,
ExtraDataJson = item.Extra != null
? System.Text.Json.JsonSerializer.Serialize(item.Extra)
: null
});
}
}
else if (adapter is IHasOwnDeviceTree tree)
{
var nodes = await tree.GetObjectTreeAsync();
FlattenTree(allDevices, nodes, adapter.AdapterCode, null);
}
}
catch (Exception ex) { Console.Error.WriteLine($"[Gateway] A3 同步失败: {adapter.AdapterCode} {ex.Message}"); }
}
if (allDevices.Any())
{
await clientFactory.SyncDevicesAsync(nodeCode, nodeToken, allDevices);
Console.WriteLine($"[Gateway] A3 设备同步完成: {allDevices.Count} 台设备");
}
}
catch (Exception ex) { Console.Error.WriteLine($"[Gateway] A1 注册失败: {ex.Message}"); }
```
**新增辅助函数**Program.cs 底部app.Run() 前):
```csharp
/// <summary>递归展平 MC4 对象树为设备列表</summary>
void FlattenTree(List<object> devices, List<DeviceTreeNode> nodes, string adapterCode, string? parentSourceId)
{
foreach (var n in nodes)
{
devices.Add(new
{
AdapterCode = adapterCode,
SourceId = n.SourceId,
Name = n.Name ?? n.SourceId,
Category = n.Tag ?? "IoT设备",
Group = "IoT设备",
IsParent = n.Type == 1,
ParentSourceId = parentSourceId,
IsOnline = true,
IpAddress = (string?)null,
Port = (int?)null,
ExtraDataJson = n.Option != null
? System.Text.Json.JsonSerializer.Serialize(n.Option)
: null
});
if (n.Children?.Count > 0)
FlattenTree(devices, n.Children, adapterCode, n.SourceId);
}
}
```
**编译验证**: `dotnet build` → 0 错误。
---
### 步骤 3: 启动 A2 后台心跳循环(预计 15min
**文件**: `gateway/src/IntegrationGateway.Host/Program.cs`
**在 A1-A3 注册/同步后追加**:
```csharp
// ── A2: 启动后台心跳(每 15 秒)──
var heartbeatInterval = int.TryParse(gwCfg["HeartbeatIntervalSec"], out var sec) ? sec : 15;
_ = Task.Run(async () =>
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(heartbeatInterval));
while (await timer.WaitForNextTickAsync())
{
try
{
await clientFactory.HeartbeatAsync(new GatewayHeartbeatRequest
{
NodeCode = nodeCode, Token = nodeToken
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"[Gateway] A2 心跳失败: {ex.Message}");
_auth?.Invalidate(); // 心跳连续失败时考虑重新注册
}
}
});
```
**appsettings.json** 已有 `"HeartbeatIntervalSec": 15`,无需改动。
**编译验证**: `dotnet build` → 0 错误。
---
### 步骤 4: 修复 RegisterNodeAsync 语法(预计 5min
**文件**: `api_sqlsugar/Warehouse/Services/device_manager/Partial/gateway_nodesService.cs`
**当前代码** (~line 55):
```csharp
var existing = _repository.DbContext.Queryable<gateway_nodes>()
.First(x => x.NodeCode == nodeCode);
```
**修改为**:
```csharp
var existing = await _repository.FindAsIQueryable(x => x.NodeCode == nodeCode)
.FirstOrDefaultAsync();
```
**同时修改 heartbeat 方法** (~line 92):
```csharp
var entity = _repository.DbContext.Queryable<gateway_nodes>()
.First(x => x.NodeCode == nodeCode && x.NodeToken == token);
```
```csharp
var entity = await _repository.FindAsIQueryable(x => x.NodeCode == nodeCode && x.NodeToken == token)
.FirstOrDefaultAsync();
```
**编译验证**: `dotnet build api_sqlsugar/Warehouse` → 0 错误。
---
### 步骤 5: 标记重复的 Upsert 逻辑(预计 10min
**文件**: `api_sqlsugar/Warehouse/Services/device_manager/Partial/base_deviceService.cs`
**在 `UpsertDeviceAsync` 方法上加 `[Obsolete]` 标记**:
```csharp
/// <summary>
/// [已废弃] 设备同步逻辑已迁移至 gateway_nodesService.SyncDevicesAsync。
/// 保留此方法仅供向后兼容,新代码请勿使用。
/// </summary>
[Obsolete("已迁移至 gateway_nodesService.SyncDevicesAsync")]
public async Task UpsertDeviceAsync(SyncDeviceItem d, int gatewayNodeId, Dictionary<(string, string), int> existingIds)
```
**同时检查 `Ibase_deviceService` 接口是否暴露了此方法** — 如是的 `Igateway_nodesService``Ibase_deviceService` 分别在两个 Partial 文件中,确认死代码无外部调用后可直接注释。
**编译验证**: `dotnet build` → 0 错误 / 仅 [Obsolete] 警告。
---
## 2. 改动文件汇总
| 步骤 | 文件 | 改动类型 | 影响 |
|:---:|------|:---:|------|
| 1 | `gateway/Program.cs` | 修改 BaseUrl 取值逻辑 | 生产部署可用真实 IP |
| 1 | `gateway/appsettings.json` | 新增 SelfUrl 字段 | 可选配置 |
| 2 | `gateway/Program.cs` | 追加 A3 同步 + FlattenTree | 首次注册即有设备 |
| 3 | `gateway/Program.cs` | 追加热心跳循环 | 网关持续在线 |
| 4 | `VolPro/gateway_nodesService.cs` | 替换 Queryable → FindAsIQueryable | 代码规范一致 |
| 5 | `VolPro/base_deviceService.cs` | 加 [Obsolete] 标记 | 消除重复逻辑 |
---
## 3. 编译顺序
```
步骤1-3: gateway → dotnet build gateway/IntegrationGateway.slnx
步骤4-5: VolPro → dotnet build api_sqlsugar/Warehouse
```
---
## 4. 验证清单
| 场景 | 预期 |
|------|------|
| 网关启动 | A1 注册成功 + A3 同步 N 台设备 + A2 心跳开始计时 |
| `gateway_nodes` 表 | 新增/更新记录BaseUrl 为真实 IP |
| `base_device` 表 | 网关对应设备的 NodeId 已回填 |
| 管理端设备列表 | 可看到 Owl/MC4/KMS 设备 |
| 30 秒后 | 网关保持在线状态LastHeartbeat 持续更新) |
| 网关重启 | NodeCode 不变 → A1 Upsert 更新 → A3 重新同步 |
---
## 5. 补充: A2 心跳 + 自动重注册机制步骤3增强版
> **日期**: 2026-06-03
> **问题**: 网关先于 Vol.Pro 启动时A1 注册失败后不重试,网关永久不可见。
### 5.1 增强后的步骤3代码
替换原步骤3的简单心跳为「心跳 + 连续失败自动重注册」:
```csharp
// ── A2: 心跳 + 自动重注册 ──
var heartbeatInterval = int.TryParse(gwCfg["HeartbeatIntervalSec"], out var sec) ? sec : 15;
var failCount = 0;
var maxFails = 3;
_ = Task.Run(async () =>
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(heartbeatInterval));
while (await timer.WaitForNextTickAsync())
{
try
{
await clientFactory.HeartbeatAsync(new GatewayHeartbeatRequest
{ NodeCode = nodeCode, Token = nodeToken });
failCount = 0;
}
catch
{
failCount++;
Console.Error.WriteLine($"[Gateway] A2 心跳失败 ({failCount}/{maxFails})");
if (failCount >= maxFails)
{
Console.WriteLine("[Gateway] 心跳连续失败, 尝试重新注册...");
try
{
await clientFactory.RegisterAsync(new GatewayRegisterRequest
{ NodeCode = nodeCode, Token = nodeToken, AdapterTypes = adapterTypes, BaseUrl = selfUrl });
await SyncAllDevicesAsync();
failCount = 0;
Console.WriteLine("[Gateway] 重新注册成功");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[Gateway] 重新注册失败: {ex.Message}");
}
}
}
}
});
```
### 5.2 重注册时序
```
网关启动 → Vol.Pro 离线 → A1 失败(仅日志) → A2 心跳循环启动(每15s)
→ 15s: 心跳失败 (1/3)
→ 30s: 心跳失败 (2/3)
→ 45s: 心跳失败 (3/3) → 触发 A1+A3 重注册 → 成功!
→ 60s: 心跳成功 (failCount=0) → 恢复正常
```
### 5.3 验证场景新增
| 场景 | 预期 |
|------|------|
| 网关先于 Vol.Pro 启动 | 45 秒后自动 A1+A3 重注册成功 |
| Vol.Pro 重启 | 网关检测到心跳失败 → 自动重新上线 |
| 网关正常运行中 | 心跳持续成功failCount=0 |
### 5.4 步骤3预计耗时更新
原 15min → 20min增加 SyncAllDevicesAsync 辅助函数和重注册分支)。

View File

@@ -0,0 +1,148 @@
# 网关 ↔ Vol.Pro 自动注册机制检查报告 2026-06-03
> **日期**: 2026-06-03
> **检查范围**: `gateway/src/IntegrationGateway.Host/Program.cs` A1 注册段 + `VolPro gateway_nodesController.cs` A1-A4 + 相关 Service
> **方法**: 逐行比对网关发送体 ↔ Vol.Pro 接收体 ↔ 数据库 Schema
---
## 1. 数据流追踪
```
网关 Program.cs (line 82-105)
├─ InitializeAllAsync() ← 适配器初始化
├─ RegisterAsync(registerReq) ← A1: POST /api/gateway/register
└─ (无后续调用) ← A2/A3 未触发
Vol.Pro gateway_nodesController
├─ [POST] /api/gateway/register ← RegisterGateway → RegisterNodeAsync
├─ [POST] /api/gateway/heartbeat ← GatewayHeartbeat → UpdateHeartbeatAsync
├─ [POST] /api/gateway/sync/devices ← SyncDevices → SyncDevicesAsync
└─ [POST] /api/gateway/sync/alarms ← SyncAlarms → UpsertAlarmAsync
```
---
## 2. 发现的问题
### 2.1 🔴 网关不调用 A3 设备同步 — 注册后设备列表为空
**现状**: 网关 Program.cs 在 A1 注册后**不调用** `clientFactory.SyncDevicesAsync()`。Vol.Pro 的 `RegisterGateway` 返回设备列表时调用 `GetDevicesByGatewayNodeAsync(node.NodeId)`,查询 `WHERE NodeId=xxx AND ParentDeviceId IS NULL`
**后果**: 首次注册返回的设备列表永远为空(数据库尚无此网关的设备记录)。设备必须等 Vol.Pro 的 Quartz `SyncDevicesJob`(每 5 分钟触发一次)才有机会同步。
**修复**: A1 注册后立即遍历适配器同步设备,整体流程改为:
```csharp
// A1 注册
var registerResult = await clientFactory.RegisterAsync(registerReq);
// A3 同步设备Owl → GetDevicesAsync, MC4 → GetObjectTreeAsync
foreach (var adapter in registry.All)
{
var devices = adapter switch
{
IHasFlatDevices f => (await f.GetDevicesAsync(1, 1000)).Items,
IHasOwnDeviceTree t => FlattenTree(await t.GetObjectTreeAsync()),
_ => new()
};
if (devices.Any())
await clientFactory.SyncDevicesAsync(nodeCode, nodeToken, devices.Select(d => new { ... }).ToList());
}
```
### 2.2 🔴 网关 A1 BaseUrl 硬编码 `localhost`
**现状**:
```csharp
BaseUrl = $"http://localhost:{app.Urls.FirstOrDefault()?.Split(':').LastOrDefault() ?? "5100"}"
```
**后果**: 网关部署在 `192.168.1.100`Vol.Pro 存的 BaseUrl 仍是 `http://localhost:5100`。Vol.Pro 端的 GatewayClient 和 Quartz Job 用此地址回调网关时全部失败。
**修复**: 改为读取配置或使用 `app.Configuration["Gateway:SelfUrl"]`,不填时降级为 `localhost`
```csharp
BaseUrl = gwCfg["SelfUrl"] ?? $"http://localhost:{port}"
```
### 2.3 🟠 网关不调用 A2 心跳 — 无持续在线状态更新
**现状**: 网关只在 A1 注册时上报一次在线状态,之后不再调 A2 心跳。
**后果**: Vol.Pro 的 `gateway_nodes.LastHeartbeat` 停留在注册时刻,`HeartbeatMonitorJob`(每 15s会在注册后 30s 将网关标记离线。
**修复**: 注册完成后启动后台心跳循环:
```csharp
_ = Task.Run(async () => {
while (true)
{
await Task.Delay(TimeSpan.FromSeconds(15));
try { await clientFactory.HeartbeatAsync(new GatewayHeartbeatRequest { NodeCode = nodeCode, Token = nodeToken }); }
catch { Console.Error.WriteLine("[Gateway] 心跳失败"); }
}
});
```
### 2.4 🟠 Vol.Pro — RegisterNodeAsync 使用旧 Queryable 语法
**现状**:
```csharp
var existing = _repository.DbContext.Queryable<gateway_nodes>()
.First(x => x.NodeCode == nodeCode);
```
`DbContext.Queryable` 是 SqlSugar 原始方式,项目中其他 Service 使用 `FindAsIQueryable`Vol.Pro 封装)。
**后果**: 不影响功能但风格不一致。且 `.First()` 可能抛异常(找不到记录时),而 `.FirstOrDefault()` + null 检查更安全。
**修复**: 改为 `FindAsIQueryable(x => x.NodeCode == nodeCode).FirstOrDefaultAsync()`
### 2.5 🟡 A1 返回结果未被网关使用
**现状**:
```csharp
var registerResult = await clientFactory.RegisterAsync(registerReq);
// registerResult 未使用
```
`RegisterGateway` 返回 `{ nodeId, devices: [...] }`,网关不读取也不使用。
**后果**: 网关不知道自己的 NodeId后续 A2/A3 需要 nodeCode + token 而非 nodeId。GatewayClientFactory 的 A2/A3 方法也用的是 nodeCode + token所以不依赖 nodeId。
**评估**: **不需要修复** — 当前设计合理nodeCode 是天然业务主键)。
### 2.6 🟡 base_deviceService 与 gateway_nodesService 重复实现设备同步
**现状**:
- `gateway_nodesService.SyncDevicesAsync` — 完整的设备同步(新增+更新)
- `base_deviceService.UpsertDeviceAsync` — 单设备 Upsert被 Controller 调用但实际未被使用)
`gateway_nodesController.SyncDevices` 调的是 `gateway_nodesService.SyncDevicesAsync` 而非 `base_deviceService.UpsertDeviceAsync`
**后果**: `base_deviceService.UpsertDeviceAsync` 是死代码。
**修复**: 保留 `gateway_nodesService.SyncDevicesAsync`(批量处理更高效),移除或标记 `base_deviceService.UpsertDeviceAsync``[Obsolete]`
---
## 3. 调用链完整性矩阵
| 接口 | Vol.Pro 端 | 网关调用 | 状态 |
|------|:---:|:---:|:--:|
| A1 /api/gateway/register | ✅ RegisterGateway | ✅ 已调用 | ⚠️ BaseUrl=localhost / 不返设备 |
| A2 /api/gateway/heartbeat | ✅ GatewayHeartbeat | ❌ 未调用 | 🔴 30秒后被标记离线 |
| A3 /api/gateway/sync/devices | ✅ SyncDevices | ❌ 未调用 | 🔴 首次注册设备列表为空 |
| A4 /api/gateway/sync/alarms | ✅ SyncAlarms | ❌ 未调用 | ⚪ 告警由 Vol.Pro Quartz 拉取 |
---
## 4. 修复优先级
| 编号 | 问题 | 严重度 | 预计 |
|:---:|------|:---:|:---:|
| 1 | A3 设备同步未触发 | 🔴 | 30min |
| 2 | A1 BaseUrl=localhost | 🔴 | 10min |
| 3 | A2 心跳未循环 | 🟠 | 15min |
| 4 | RegisterNodeAsync 语法不一致 | 🟠 | 5min |
| 5 | 重复的设备 Upsert 逻辑 | 🟡 | 10min |

Some files were not shown because too many files have changed in this diff Show More