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