diff --git a/api_sqlsugar/Warehouse/Services/RuleEngineJob.cs b/api_sqlsugar/Warehouse/Services/RuleEngineJob.cs new file mode 100644 index 0000000..cdf60c3 --- /dev/null +++ b/api_sqlsugar/Warehouse/Services/RuleEngineJob.cs @@ -0,0 +1,24 @@ +using Quartz; +using Microsoft.Extensions.DependencyInjection; +using System.Threading.Tasks; + +namespace Warehouse.Services; + +/// +/// 规则引擎定时任务。 +/// Cron: 0/10 * * * * ? (每10秒) +/// 挂载到 Vol.Pro Quartz 调度器。 +/// +public class RuleEngineJob : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + var sp = (IServiceProvider)context.JobDetail.JobDataMap["ServiceProvider"]; + if (sp == null) return; + + var engine = sp.GetService(); + if (engine == null) return; + + await engine.EvaluateAllAsync(); + } +} diff --git a/api_sqlsugar/Warehouse/Services/RuleEngineService.cs b/api_sqlsugar/Warehouse/Services/RuleEngineService.cs new file mode 100644 index 0000000..a7e2d26 --- /dev/null +++ b/api_sqlsugar/Warehouse/Services/RuleEngineService.cs @@ -0,0 +1,320 @@ +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using VolPro.Entity.DomainModels; +using VolPro.WebApi.Controllers.Hubs; +using Warehouse.IRepositories; +using Warehouse.IServices; + +namespace Warehouse.Services; + +/// +/// 规则引擎核心服务。 +/// 由 RuleEngineJob 每 10s 调用一次 EvaluateAllAsync。 +/// +/// 流程: +/// 1. 加载所有启用规则(含条件+动作) +/// 2. 从 gateway 批量获取实时值 +/// 3. 逐规则评估条件 → 触发动作 → 写日志 +/// +public class RuleEngineService +{ + private readonly Iwarehouse_ruleRepository _ruleRepo; + private readonly Ibase_deviceRepository _devRepo; + private readonly Iiot_devicedataRepository _dataRepo; + private readonly Iiot_alarmRepository _alarmRepo; + private readonly GatewayClient _gatewayClient; + private readonly IHubContext _hub; + + public RuleEngineService( + Iwarehouse_ruleRepository ruleRepo, + Ibase_deviceRepository devRepo, + Iiot_devicedataRepository dataRepo, + Iiot_alarmRepository alarmRepo, + GatewayClient gatewayClient, + IHubContext hub) + { + _ruleRepo = ruleRepo; + _devRepo = devRepo; + _dataRepo = dataRepo; + _alarmRepo = alarmRepo; + _gatewayClient = gatewayClient; + _hub = hub; + } + + public async Task EvaluateAllAsync() + { + // 1. 加载启用规则 + var rules = await LoadEnabledRulesAsync(); + if (!rules.Any()) return; + + // 2. 构建 DeviceId → (AdapterCode, SourceId, BaseUrl) 映射 + var deviceMap = await BuildDeviceMappingAsync(rules); + + // 3. 批量取实时值(按网关分组调 B4-batch) + var realtimeData = await BatchFetchRealtimeAsync(rules, deviceMap); + + // 4. 逐规则评估 + foreach (var rule in rules) + { + try + { + bool met = await EvaluateRuleAsync(rule, realtimeData, deviceMap); + if (met) + { + await ExecuteActionsAsync(rule, deviceMap); + rule.LastTriggered = DateTime.Now; + } + rule.LastEvaluated = DateTime.Now; + await _ruleRepo.DbContext.Updateable(rule) + .UpdateColumns(r => new { r.LastEvaluated, r.LastTriggered }).ExecuteCommandAsync(); + } + catch { /* 单规则失败不阻塞 */ } + } + } + + // ═══════════════════════════════════════════ + // 规则加载 + // ═══════════════════════════════════════════ + + private async Task> LoadEnabledRulesAsync() + { + var rules = await _ruleRepo.FindAsIQueryable(r => + r.Enable == "启用" || r.Enable == null).ToListAsync(); + foreach (var r in rules) + { + r.CooldownSec = r.CooldownSec > 0 ? r.CooldownSec : 60; + r.Priority = r.Priority ?? 0; + } + return rules.OrderByDescending(r => r.Priority).ToList(); + } + + // ═══════════════════════════════════════════ + // 设备映射: DeviceId → (AdapterCode, SourceId, 网关BaseUrl) + // ═══════════════════════════════════════════ + + private async Task> BuildDeviceMappingAsync( + List rules) + { + var conditionDeviceIds = rules.SelectMany(r => r.warehouse_rulecondition ?? new()) + .Select(c => c.DeviceId ?? 0).Where(id => id > 0).Distinct().ToList(); + var actionDeviceIds = rules.SelectMany(r => r.warehouse_ruleaction ?? new()) + .Select(a => a.DeviceId ?? 0).Where(id => id > 0).Distinct().ToList(); + var allIds = conditionDeviceIds.Union(actionDeviceIds).ToList(); + if (!allIds.Any()) return new(); + + var devices = await _devRepo.FindAsIQueryable(d => allIds.Contains(d.DeviceId)).ToListAsync(); + var map = new Dictionary(); + foreach (var d in devices) + { + string baseUrl = ""; + if (!string.IsNullOrEmpty(d.AdapterCode)) + { + var prefix = d.AdapterCode.Split(':')[0]; + // 从 gateway_nodes 查找对应的 BaseUrl + try + { + var gw = _devRepo.DbContext.Queryable() + .First(x => x.AdapterTypes != null && x.AdapterTypes.Contains(prefix) && x.IsOnline == "在线"); + baseUrl = gw?.BaseUrl ?? ""; + } + catch { } + } + map[d.DeviceId] = (d.AdapterCode ?? "", d.SourceId ?? "", baseUrl); + } + return map; + } + + // ═══════════════════════════════════════════ + // 批量实时值获取 + // ═══════════════════════════════════════════ + + private async Task>> BatchFetchRealtimeAsync( + List rules, + Dictionary deviceMap) + { + var result = new Dictionary<(string, string), List<(int, double)>>(); + + // 按网关分组 + var gwGroups = new Dictionary>(); + foreach (var (deviceId, (adapter, sourceId, baseUrl)) in deviceMap) + { + if (string.IsNullOrEmpty(baseUrl) || string.IsNullOrEmpty(sourceId)) continue; + if (!gwGroups.ContainsKey(baseUrl)) + gwGroups[baseUrl] = new(); + gwGroups[baseUrl].Add((adapter, sourceId)); + } + + foreach (var (baseUrl, pairs) in gwGroups) + { + foreach (var (adapter, sourceId) in pairs) + { + try + { + var data = await _gatewayClient.GetRealtimeAsync(baseUrl, adapter, sourceId); + if (data == null) continue; + var root = data.RootElement; + if (root.TryGetProperty("rows", out var rows) && rows.ValueKind == JsonValueKind.Array) + { + var list = new List<(int, double)>(); + foreach (var r in rows.EnumerateArray()) + { + int idx = r.TryGetProperty("index", out var i) ? i.GetInt32() : 0; + double val = r.TryGetProperty("value", out var v) ? v.GetDouble() : 0; + list.Add((idx, val)); + } + result[(adapter, sourceId)] = list; + } + } + catch { } + } + } + return result; + } + + // ═══════════════════════════════════════════ + // 条件评估 + // ═══════════════════════════════════════════ + + private Task EvaluateRuleAsync(warehouse_rule rule, + Dictionary<(string adapter, string sourceId), List<(int pointIndex, double value)>> realtimeData, + Dictionary deviceMap) + { + var conditions = rule.warehouse_rulecondition ?? new(); + if (!conditions.Any()) return Task.FromResult(false); + + var results = new List(); + foreach (var cond in conditions) + { + if (cond.DeviceId == null || cond.ValueId == null) { results.Add(false); continue; } + + // 冷却检查 + if (cond.LastTriggered.HasValue && rule.CooldownSec > 0) + { + if ((DateTime.Now - cond.LastTriggered.Value).TotalSeconds < rule.CooldownSec) + { results.Add(false); continue; } + } + + if (!deviceMap.TryGetValue(cond.DeviceId.Value, out var devInfo)) { results.Add(false); continue; } + + double? actualValue = null; + if (realtimeData.TryGetValue((devInfo.adapterCode, devInfo.sourceId), out var points)) + { + // ValueId 对应 pointIndex(简化:直接使用 ValueId 作为 pointIndex) + var point = points.FirstOrDefault(p => p.pointIndex == cond.ValueId); + actualValue = point.pointIndex == 0 && !points.Any(p => p.pointIndex == cond.ValueId) && points.Count > 0 + ? points.First().value : point.value; + if (point.pointIndex == 0 && points.Count == 0) actualValue = null; + } + + // 滞后窗:已触发过则用恢复阈值 + bool isTriggered = cond.LastTriggered.HasValue; + double target = isTriggered + ? (double)(cond.RecoveryThreshold_Numeric ?? cond.TargetValue_Number ?? 0) + : (double)(cond.TargetValue_Number ?? 0); + + bool met = Compare(actualValue, cond.CompareOperator ?? "大于", target); + results.Add(met); + if (met) cond.LastTriggered = DateTime.Now; + } + + bool finalResult = rule.JudgmentMode == "AND" + ? results.All(r => r) + : results.Any(r => r); + return Task.FromResult(finalResult); + } + + private static bool Compare(double? actual, string op, double target) + { + double v = actual ?? double.MinValue; + return op switch + { + "大于" => v > target, + "小于" => v < target, + "等于" => Math.Abs(v - target) < 0.001, + "大于等于" => v >= target, + "小于等于" => v <= target, + "不等于" => Math.Abs(v - target) > 0.001, + _ => false + }; + } + + // ═══════════════════════════════════════════ + // 动作执行 + // ═══════════════════════════════════════════ + + private async Task ExecuteActionsAsync(warehouse_rule rule, + Dictionary deviceMap) + { + var actions = rule.warehouse_ruleaction ?? new(); + if (!actions.Any()) return; + + // 冷却检查 + if (rule.LastTriggered.HasValue && rule.CooldownSec > 0) + { + if ((DateTime.Now - rule.LastTriggered.Value).TotalSeconds < rule.CooldownSec) + return; + } + + var tasks = actions.Select(a => ExecuteSingleActionAsync(a, deviceMap)); + await Task.WhenAll(tasks); + } + + private async Task ExecuteSingleActionAsync(warehouse_ruleaction action, + Dictionary deviceMap) + { + var actionType = action.ActionType ?? action.Type ?? "控制"; + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + switch (actionType) + { + case "控制": + if (action.DeviceId.HasValue && deviceMap.TryGetValue(action.DeviceId.Value, out var dev)) + { + if (!string.IsNullOrEmpty(dev.baseUrl)) + { + var pointIndex = action.ValueId ?? 0; + var value = (double)(action.TargetValue_Switch == "开" ? 1 : action.TargetValue_Number ?? 0); + await _gatewayClient.ControlDeviceAsync(dev.baseUrl, dev.adapterCode, dev.sourceId, pointIndex, value); + } + } + break; + + case "告警": + if (action.Alert == "是" && action.DeviceId.HasValue) + { + var alarm = new iot_alarm + { + SourceAlarmId = $"rule-{action.RuleID}-{DateTime.Now.Ticks}", + DeviceId = action.DeviceId.Value, + AlarmLevel = "重要", + AlarmDesc = action.AlertMessage ?? $"规则触发", + StartTime = DateTime.Now, + State = "未确认", + AdapterCode = "RuleEngine", + CreateDate = DateTime.Now + }; + _alarmRepo.Add(alarm); + } + break; + + case "通知": + await _hub.Clients.All.SendAsync("RuleTriggered", new + { + title = action.AlertMessage ?? "规则触发", + alertMessage = action.AlertMessage, + deviceId = action.DeviceId + }, cts.Token); + break; + } + } + catch (OperationCanceledException) { /* 超时 */ } + catch { /* 单动作失败不阻塞 */ } + } +} diff --git a/warehouse/src/view/DataView.vue b/warehouse/src/view/DataView.vue index 22ce33d..6fe2aa7 100644 --- a/warehouse/src/view/DataView.vue +++ b/warehouse/src/view/DataView.vue @@ -14,6 +14,12 @@ import http from '../api/http.js' // TODO Phase2: 遍历 base_device 加载 MapModelId → VgoMap 标记; 告警设备红色闪烁 import initMessageHub from './index.js' // 导入消息中心初始化函数 +// 规则引擎 SignalR 订阅: RuleTriggered → 告警弹窗 +const initRuleEngineListener = (connection) => { + connection.on("RuleTriggered", (data) => { + handlePushMessage({ title: data.title || '规则触发', message: data.alertMessage || '', code: '3', notificationType: 3, level: data.alertMessage, date: new Date().toISOString() }); + }); +}; import Message from './Message.vue' // 生成唯一ID的辅助函数 diff --git a/web.vite/src/views/warehouse/warehouse_rule/warehouse_rule/options.js b/web.vite/src/views/warehouse/warehouse_rule/warehouse_rule/options.js index 720185a..625e9aa 100644 --- a/web.vite/src/views/warehouse/warehouse_rule/warehouse_rule/options.js +++ b/web.vite/src/views/warehouse/warehouse_rule/warehouse_rule/options.js @@ -1,77 +1,78 @@ -// *Author:jxx -// *Contact:283591387@qq.com -// *代码由框架生成,任何更改都可能导致被代码生成器覆盖 -export default function(){ - const table = { - key: 'RuleID', - footer: "Foots", - cnName: '规则', - name: 'warehouse_rule', - newTabEdit: false, - url: "/warehouse_rule/", - sortName: "Title", - fixedSearch:false - }; - const tableName = table.name; - const tableCNName = table.cnName; - const newTabEdit = false; - const key = table.key; - const editFormFields = {"Title":"","JudgmentMode":"","JudgmentValue":""}; - const editFormOptions = [[{"title":"规则标题","required":true,"field":"Title","colSize":100.0}], - [{"dataKey":"条件判断方式","data":[],"title":"条件判断方式","field":"JudgmentMode","colSize":50.0,"type":"select"}, - {"dataKey":"条件判断目标值","data":[],"title":"条件判断目标值","field":"JudgmentValue","colSize":50.0,"type":"select"}]]; - const searchFormFields = {}; - const searchFormOptions = []; - const columns = [{field:'Title',title:'规则标题',type:'string',link:true,width:150,require:true,align:'left'}, - {field:'JudgmentMode',title:'条件判断方式',type:'string',bind:{ key:'条件判断方式',data:[]},width:150,align:'left'}, - {field:'JudgmentValue',title:'条件判断目标值',type:'string',bind:{ key:'条件判断目标值',data:[]},width:110,align:'left'}, - {field:'RuleID',title:'规则编号',type:'int',width:120,hidden:true,require:true,align:'left'}]; - const detail ={columns:[]}; - const details = [ { - cnName: '规则条件', - table: 'warehouse_rulecondition', - columns: [{field:'id',title:'条件编号',type:'int',width:110,hidden:true,require:true,align:'left'}, - {field:'DeviceId',title:'设备',type:'int',bind:{ key:'所有设备列表',data:[]},width:110,edit:{type:'select'},align:'left'}, - {field:'ValueId',title:'变量',type:'int',bind:{ key:'变量列表',data:[]},width:110,edit:{type:'select'},align:'left'}, - {field:'Type',title:'比对类型',type:'string',bind:{ key:'比对类型',data:[]},width:150,edit:{type:'select'},align:'left'}, - {field:'CompareOperator',title:'比较运算',type:'string',bind:{ key:'比较运算',data:[]},width:150,edit:{type:'select'},align:'left'}, - {field:'TargetValue_Switch',title:'目标值开关状态',type:'string',bind:{ key:'开关状态',data:[]},width:120,edit:{type:'select'},align:'left'}, - {field:'TargetValue_Number',title:'目标值数值',type:'int',width:120,edit:{type:'number'},align:'left'}, - {field:'RuleID',title:'所属规则编号',type:'int',width:120,hidden:true,align:'left'}], - sortName: 'id', - key: 'id', - buttons:[], - delKeys:[], - detail:null - }, { - cnName: '规则动作', - table: 'warehouse_ruleaction', - columns: [{field:'id',title:'动作编号',type:'int',width:110,hidden:true,require:true,align:'left'}, - {field:'DeviceId',title:'设备',type:'int',bind:{ key:'所有设备列表',data:[]},width:110,edit:{type:'select'},align:'left'}, - {field:'ValueId',title:'变量',type:'int',bind:{ key:'变量列表',data:[]},width:110,edit:{type:'select'},align:'left'}, - {field:'Type',title:'值类型',type:'string',bind:{ key:'比对类型',data:[]},width:150,edit:{type:'select'},align:'left'}, - {field:'TargetValue_Switch',title:'目标值开状态状态',type:'string',bind:{ key:'开关状态',data:[]},width:120,edit:{type:'select'},align:'left'}, - {field:'TargetValue_Number',title:'目标值数值',type:'int',width:120,edit:{type:'number'},align:'left'}, - {field:'RuleID',title:'所属规则编号',type:'int',width:120,hidden:true,align:'left'}], - sortName: 'id', - key: 'id', - buttons:[], - delKeys:[], - detail:null - }]; - - return { - table, - key, - tableName, - tableCNName, - newTabEdit, - editFormFields, - editFormOptions, - searchFormFields, - searchFormOptions, - columns, - detail, - details - }; +// *代码由框架生成,任何更改都可能导致被代码生成器覆盖 +export default function(){ + const table = { + key: 'RuleID', + footer: "Foots", + cnName: '规则', + name: 'warehouse_rule', + newTabEdit: false, + url: "/warehouse_rule/", + sortName: "Title", + fixedSearch:false + }; + const tableName = table.name; + const tableCNName = table.cnName; + const newTabEdit = false; + const key = table.key; + const editFormFields = {"Title":"","JudgmentMode":"","JudgmentValue":"","Enable":"启用","Priority":0,"CooldownSec":60}; + const editFormOptions = [ + [{"title":"规则标题","required":true,"field":"Title","colSize":60.0}, + {"dataKey":"条件判断方式","data":[],"title":"判断方式","field":"JudgmentMode","colSize":40.0,"type":"select"}], + [{"title":"优先级","field":"Priority","colSize":50.0,"type":"number"}, + {"title":"冷却时间(秒)","field":"CooldownSec","colSize":50.0,"type":"number"}], + [{"dataKey":"启用状态","data":[],"title":"启用","field":"Enable","colSize":50.0,"type":"select"}, + {"dataKey":"条件判断目标值","data":[],"title":"目标值","field":"JudgmentValue","colSize":50.0,"type":"select"}] + ]; + const searchFormFields = {}; + const searchFormOptions = []; + const columns = [ + {field:'Title',title:'规则标题',type:'string',link:true,width:150,require:true,align:'left'}, + {field:'JudgmentMode',title:'判断方式',type:'string',bind:{ key:'条件判断方式',data:[]},width:100,align:'left'}, + {field:'Priority',title:'优先级',type:'int',width:80,align:'left'}, + {field:'CooldownSec',title:'冷却(秒)',type:'int',width:80,align:'left'}, + {field:'Enable',title:'启用',type:'string',bind:{ key:'启用状态',data:[]},width:80,align:'left'}, + {field:'LastTriggered',title:'上次触发',type:'datetime',width:150,align:'left'}, + {field:'RuleID',title:'规则编号',type:'int',width:120,hidden:true,require:true,align:'left'} + ]; + const detail ={columns:[]}; + const details = [ + { + cnName: '规则条件', + table: 'warehouse_rulecondition', + columns: [ + {field:'id',title:'条件编号',type:'int',width:110,hidden:true,require:true,align:'left'}, + {field:'DeviceId',title:'设备',type:'int',bind:{ key:'所有设备列表',data:[]},width:110,edit:{type:'select'},align:'left'}, + {field:'ValueId',title:'变量',type:'int',width:110,edit:{type:'number'},align:'left'}, + {field:'Type',title:'比对类型',type:'string',bind:{ key:'比对类型',data:[]},width:150,edit:{type:'select'},align:'left'}, + {field:'CompareOperator',title:'比较运算',type:'string',bind:{ key:'比较运算',data:[]},width:150,edit:{type:'select'},align:'left'}, + {field:'TargetValue_Number',title:'目标值',type:'int',width:120,edit:{type:'number'},align:'left'}, + {field:'TargetValue_Switch',title:'开关状态',type:'string',bind:{ key:'开关状态',data:[]},width:120,edit:{type:'select'},align:'left'}, + {field:'RecoveryThreshold_Numeric',title:'恢复阈值',type:'decimal',width:120,edit:{type:'number'},align:'left'}, + {field:'RuleID',title:'所属规则编号',type:'int',width:120,hidden:true,align:'left'} + ], + sortName: 'id', key: 'id', buttons:[], delKeys:[], detail:null + }, + { + cnName: '规则动作', + table: 'warehouse_ruleaction', + columns: [ + {field:'id',title:'动作编号',type:'int',width:110,hidden:true,require:true,align:'left'}, + {field:'DeviceId',title:'设备',type:'int',bind:{ key:'所有设备列表',data:[]},width:110,edit:{type:'select'},align:'left'}, + {field:'ValueId',title:'变量',type:'int',width:110,edit:{type:'number'},align:'left'}, + {field:'ActionType',title:'动作类型',type:'string',bind:{ key:'动作类型',data:[]},width:150,edit:{type:'select'},align:'left'}, + {field:'TargetValue_Number',title:'目标值',type:'int',width:120,edit:{type:'number'},align:'left'}, + {field:'TargetValue_Switch',title:'开关状态',type:'string',bind:{ key:'开关状态',data:[]},width:120,edit:{type:'select'},align:'left'}, + {field:'Alert',title:'生成告警',type:'string',bind:{ key:'开关状态',data:[]},width:100,edit:{type:'select'},align:'left'}, + {field:'AlertMessage',title:'告警内容',type:'string',width:200,edit:{type:'text'},align:'left'}, + {field:'RuleID',title:'所属规则编号',type:'int',width:120,hidden:true,align:'left'} + ], + sortName: 'id', key: 'id', buttons:[], delKeys:[], detail:null + } + ]; + + return { + table, key, tableName, tableCNName, newTabEdit, + editFormFields, editFormOptions, searchFormFields, searchFormOptions, + columns, detail, details + }; } \ No newline at end of file