Compare commits

..
11 Commits
Author SHA1 Message Date
dengboling 60fac00887 完善系统参数配置和数据字典管理模块 2026-09-18 17:22:56 +08:00
dengbolingandClaude Sonnet 4.6 6aca0297bb feat: 网关管理模块 + RBAC权限认证 + 组织架构 + 审计日志
- 网关管理:GatewayEntity/Service/Controller CRUD + 测试连接;设备 GatewayId 外键关联
- JWT 认证:登录签发 Token(权限编码写入 Claims)、RequirePermission 权限过滤器、CurrentUser 上下文
- RBAC:用户/角色/权限实体与 CRUD,预定义 4 角色 + 6 权限种子(admin/admin123)
- 组织架构:sys_org 固定层级树(公司/实验室/部门/班组白夜班),层级校验,用户挂 OrgId/岗位/技能标签
- 数据权限:角色 DataScope(全部/本组织及下级),用户列表按组织子树过滤
- 防锁死保护:禁止删/禁自己,保证至少一名活跃管理员,角色摘除 user:manage 前校验
- 审计日志:AuditRecorder 接入设备增删改/指令下发/登录登出/组织变更,AuditController 查询
- 设备指令下发按网关表取连接参数;设备列表支持 gatewayId/productId 筛选

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-10 17:31:39 +08:00
“hsc” 3059befcf5 Merge branch 'master' of https://git.kaiyili-lab.com/huangsucan/IOT_API 2026-09-04 17:38:57 +08:00
dengbolingandClaude Code 19fb191c3d Merge remote-tracking branch 'origin/master'(告警中心/通知模块 与 产品/物模型模块 合并,EntityMapper.cs 保留双方映射区域)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-03 17:33:12 +08:00
dengboling bcd6484f45 设备类添加产品字段、添加物模型类,新增产品CRUD方法 2026-09-03 17:16:28 +08:00
wuyi 3927b0c201 搭建消息告警模块以及跨网推送服务,支持企微,飞书,钉钉通知 2026-09-03 14:02:35 +08:00
“hsc” 38cd47410c 设备维修统计报表 2026-09-02 13:36:52 +08:00
dengboling cfa8e78d33 添加设备类、设备日志类、设备服务类、设备控制器 2026-08-28 17:28:16 +08:00
“hsc” 6c20630a40 冲突处理 2026-08-28 16:08:03 +08:00
“hsc” 2ecd921d26 Merge branch 'master' of https://git.kaiyili-lab.com/huangsucan/IOT_API 2026-08-28 15:20:58 +08:00
“hsc” d9b079142b 通知告警规则引擎 2026-08-28 15:20:52 +08:00
146 changed files with 14260 additions and 89 deletions
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<!-- 引用 Common:复用 ImWebhookSigner 加签算法与 Model 中的 Outbox DTO,保证与主站推送行为一致 -->
<ProjectReference Include="..\Common\Common.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
+259
View File
@@ -0,0 +1,259 @@
using Common.Notify;
using Model.Dto.Inspection;
using System.Text;
using System.Text.Json;
namespace AlertBridge
{
/// <summary>
/// 跨网告警转发程序(部署在可同时访问"工控内网 IOT_API"与"外网 IM Webhook"的跳板机上)
/// 工作方式:轮询拉取内网发件箱(GET outbox/pull)→ 按报文推送飞书/钉钉/企微 → 回写结果(POST outbox/{id}/result
/// 报文自包含 WebhookUrl/Secret/内容,本程序无需访问数据库,配置仅需 appsettings.json 三项
/// </summary>
public class Program
{
private static string _apiBaseUrl = "http://localhost:5287";
private static int _pollSeconds = 5;
private static int _batchSize = 20;
public static async Task Main(string[] args)
{
LoadConfig();
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
Log($"AlertBridge 启动,API 地址: {_apiBaseUrl},轮询间隔: {_pollSeconds}s,批量: {_batchSize}");
Log("按 Ctrl+C 停止");
while (!cts.IsCancellationRequested)
{
try
{
await PollOnceAsync(http, cts.Token);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Log($"轮询异常: {ex.Message}");
}
try { await Task.Delay(TimeSpan.FromSeconds(_pollSeconds), cts.Token); }
catch (TaskCanceledException) { break; }
}
Log("AlertBridge 已停止");
}
/// <summary>
/// 单次轮询:拉取 → 逐条推送 → 回写
/// </summary>
private static async Task PollOnceAsync(HttpClient http, CancellationToken ct)
{
string pullUrl = $"{_apiBaseUrl}/api/inspection/alert-notify/outbox/pull?batch={_batchSize}";
string pullJson = await http.GetStringAsync(pullUrl, ct);
var pullResult = JsonSerializer.Deserialize<ResultDto<List<AlertOutboxItemDto>>>(pullJson, JsonOpts);
if (pullResult == null || pullResult.Code != 0 || pullResult.Data == null || pullResult.Data.Count == 0)
{
return;
}
Log($"拉取到 {pullResult.Data.Count} 条待推送消息");
foreach (var item in pullResult.Data)
{
bool success = false;
string? error = null;
try
{
var payload = JsonSerializer.Deserialize<AlertOutboxPayload>(item.Payload ?? "{}", JsonOpts);
if (payload == null || string.IsNullOrWhiteSpace(payload.WebhookUrl))
{
error = "Payload 为空或缺少 WebhookUrl";
}
else
{
(success, error) = await SendAsync(http, payload, ct);
}
}
catch (Exception ex)
{
error = NotifyError.Describe(ex);
}
// 回写结果
try
{
var resultBody = JsonSerializer.Serialize(new { Success = success, ResultMsg = error }, JsonOpts);
using var content = new StringContent(resultBody, Encoding.UTF8, "application/json");
await http.PostAsync($"{_apiBaseUrl}/api/inspection/alert-notify/outbox/{item.Id}/result", content, ct);
Log($"消息 {item.Id} 推送{(success ? "" : $": {error}")},结果已回写");
}
catch (Exception ex)
{
Log($"消息 {item.Id} 结果回写失败: {ex.Message}");
}
}
}
/// <summary>
/// 按渠道类型推送(与主站 Notifier 报文格式保持一致)
/// </summary>
private static async Task<(bool success, string? error)> SendAsync(HttpClient http, AlertOutboxPayload p, CancellationToken ct)
{
string url = p.WebhookUrl!;
object body;
switch (p.ChannelType)
{
case 1: // 飞书:post 富文本 + at 标签(user_id=open_id) 真正 @ + 可选加签(timestamp 秒 + sign
{
var contentLines = new List<List<Dictionary<string, object>>>();
foreach (var raw in (p.Content ?? string.Empty).Split('\n'))
{
var line = raw.TrimEnd('\r');
if (line.Length == 0) continue;
contentLines.Add(new List<Dictionary<string, object>>
{
new Dictionary<string, object> { ["tag"] = "text", ["text"] = line }
});
}
if (p.AtMobiles is { Count: > 0 })
{
var atLine = new List<Dictionary<string, object>>
{
new Dictionary<string, object> { ["tag"] = "text", ["text"] = "关注人: " }
};
foreach (var openId in p.AtMobiles)
{
if (string.IsNullOrWhiteSpace(openId)) continue;
atLine.Add(new Dictionary<string, object> { ["tag"] = "at", ["user_id"] = openId.Trim() });
}
contentLines.Add(atLine);
}
var feishuBody = new Dictionary<string, object>
{
["msg_type"] = "post",
["content"] = new Dictionary<string, object>
{
["post"] = new Dictionary<string, object>
{
["zh_cn"] = new Dictionary<string, object>
{
["title"] = p.Title ?? "设备告警",
["content"] = contentLines
}
}
}
};
if (!string.IsNullOrWhiteSpace(p.Secret))
{
long ts = DateTimeOffset.Now.ToUnixTimeSeconds();
feishuBody["timestamp"] = ts.ToString();
feishuBody["sign"] = ImWebhookSigner.FeishuSign(ts, p.Secret);
}
body = feishuBody;
break;
}
case 2: // 钉钉:markdown + at.atMobiles + URL 加签(timestamp 毫秒 + sign
{
if (!string.IsNullOrWhiteSpace(p.Secret))
{
long ts = DateTimeOffset.Now.ToUnixTimeMilliseconds();
url += $"{(url.Contains('?') ? "&" : "?")}timestamp={ts}&sign={ImWebhookSigner.DingTalkSign(ts, p.Secret)}";
}
var md = new StringBuilder();
md.AppendLine($"### {p.Title}");
md.AppendLine(p.Content);
foreach (var m in p.AtMobiles ?? new List<string>()) md.Append($"@{m} ");
body = new Dictionary<string, object>
{
["msgtype"] = "markdown",
["markdown"] = new Dictionary<string, object> { ["title"] = p.Title ?? "设备告警", ["text"] = md.ToString() },
["at"] = new Dictionary<string, object> { ["atMobiles"] = p.AtMobiles ?? new List<string>(), ["isAtAll"] = false }
};
break;
}
case 3: // 企微:text + mentioned_mobile_list(无加签)
{
var text = new StringBuilder();
text.AppendLine(p.Title);
text.AppendLine(p.Content);
body = new Dictionary<string, object>
{
["msgtype"] = "text",
["text"] = new Dictionary<string, object>
{
["content"] = text.ToString(),
["mentioned_mobile_list"] = p.AtMobiles ?? new List<string>()
}
};
break;
}
default:
return (false, $"未知渠道类型: {p.ChannelType}");
}
string json = JsonSerializer.Serialize(body, JsonOpts);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await http.PostAsync(url, content, ct);
string respText = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
return (false, $"HTTP {(int)response.StatusCode}: {Truncate(respText)}");
}
// 飞书返回 code/StatusCode,钉钉/企微返回 errcode0 均为成功
using var doc = JsonDocument.Parse(respText);
var root = doc.RootElement;
bool ok = (root.TryGetProperty("errcode", out var ec) && ec.GetInt32() == 0)
|| (root.TryGetProperty("code", out var c) && c.GetInt32() == 0)
|| (root.TryGetProperty("StatusCode", out var sc) && sc.GetInt32() == 0);
return ok ? (true, null) : (false, Truncate(respText));
}
/// <summary>
/// 读取 appsettings.json(免依赖 Microsoft.Extensions.Configuration,直接 JSON 解析)
/// </summary>
private static void LoadConfig()
{
try
{
string path = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (!File.Exists(path)) return;
using var doc = JsonDocument.Parse(File.ReadAllText(path));
var root = doc.RootElement;
if (root.TryGetProperty("ApiBaseUrl", out var u)) _apiBaseUrl = u.GetString() ?? _apiBaseUrl;
if (root.TryGetProperty("PollSeconds", out var p)) _pollSeconds = p.GetInt32();
if (root.TryGetProperty("BatchSize", out var b)) _batchSize = b.GetInt32();
}
catch (Exception ex)
{
Log($"读取配置失败,使用默认值: {ex.Message}");
}
}
private static string Truncate(string s, int max = 200) =>
string.IsNullOrEmpty(s) || s.Length <= max ? s : s.Substring(0, max) + "...";
private static void Log(string msg) => Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {msg}");
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
/// <summary>
/// 主站统一返回结构(仅取所需字段)
/// </summary>
private class ResultDto<T>
{
public int Code { get; set; }
public string? Msg { get; set; }
public T? Data { get; set; }
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"ApiBaseUrl": "http://localhost:5287",
"PollSeconds": 5,
"BatchSize": 20
}
+26
View File
@@ -0,0 +1,26 @@
namespace Common.Notify
{
/// <summary>
/// 飞书自建应用配置(用于"手机号 → open_id"转换)
/// 飞书群机器人 webhook 只能发文本、无法用手机号 @ 人,必须借助自建应用凭证调通讯录接口换取 open_id。
/// 由 Program.cs 启动时从 appsettings.json 的 Feishu 节读入;未配置时相关功能自动降级。
/// </summary>
public static class FeishuConfig
{
/// <summary>飞书自建应用 App IDcli_ 开头)</summary>
public static string AppId { get; private set; } = string.Empty;
/// <summary>飞书自建应用 App Secret</summary>
public static string AppSecret { get; private set; } = string.Empty;
/// <summary>是否已配置(未配置时手机号无法转 open_id,降级为仅支持直接填 open_id / all</summary>
public static bool IsConfigured => !string.IsNullOrWhiteSpace(AppId) && !string.IsNullOrWhiteSpace(AppSecret);
/// <summary>初始化飞书应用配置</summary>
public static void Init(string appId, string appSecret)
{
AppId = appId ?? string.Empty;
AppSecret = appSecret ?? string.Empty;
}
}
}
+40
View File
@@ -0,0 +1,40 @@
using System.Security.Cryptography;
using System.Text;
namespace Common.Notify
{
/// <summary>
/// IM 机器人 Webhook 加签工具(钉钉/飞书)
/// 供 IOT_API 直连推送与 AlertBridge 跨网转发程序共用,保证两种模式签名算法一致
/// </summary>
public static class ImWebhookSigner
{
/// <summary>
/// 钉钉机器人加签:sign = UrlEncode(Base64(HmacSHA256(timestamp + "\n" + secret, key=secret)))
/// </summary>
/// <param name="timestampMs">毫秒时间戳</param>
/// <param name="secret">加签密钥(SEC 开头)</param>
/// <returns>URL 编码后的签名</returns>
public static string DingTalkSign(long timestampMs, string secret)
{
string stringToSign = $"{timestampMs}\n{secret}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
byte[] signData = hmac.ComputeHash(Encoding.UTF8.GetBytes(stringToSign));
return Uri.EscapeDataString(Convert.ToBase64String(signData));
}
/// <summary>
/// 飞书机器人加签:sign = Base64(HmacSHA256(空串, key=timestamp + "\n" + secret))
/// 注意与钉钉相反:飞书以"时间戳\n密钥"为 key、对空内容签名
/// </summary>
/// <param name="timestampSec">秒级时间戳</param>
/// <param name="secret">加签密钥</param>
/// <returns>Base64 签名</returns>
public static string FeishuSign(long timestampSec, string secret)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes($"{timestampSec}\n{secret}"));
byte[] signData = hmac.ComputeHash(Array.Empty<byte>());
return Convert.ToBase64String(signData);
}
}
}
+83
View File
@@ -0,0 +1,83 @@
using System.Security.Authentication;
using System.Text;
namespace Common.Notify
{
/// <summary>
/// 通知推送异常诊断工具
/// HttpClient 网络异常(尤其 SSL/TLS 握手失败)的真实根因藏在 InnerException 链里,
/// 只记录外层 ex.Message 会得到"see inner exception"这类无用信息。
/// 本工具展开整条异常链并针对常见网络故障给出可读排查提示,供三个 Notifier 复用。
/// </summary>
public static class NotifyError
{
/// <summary>
/// 将异常展开为"外层 -> 内层 -> ..."的可读描述,并附加常见网络故障排查提示
/// </summary>
public static string Describe(Exception? ex)
{
if (ex == null) return "未知错误";
var sb = new StringBuilder();
var current = ex;
int depth = 0;
// 最多展开 5 层,防止极端嵌套导致信息过长
while (current != null && depth < 5)
{
if (depth > 0) sb.Append(" -> ");
sb.Append(current.Message);
current = current.InnerException;
depth++;
}
if (IsTimeout(ex))
{
sb.Append("|排查:请求超时,请确认目标地址可达、Webhook 域名解析正常、无防火墙拦截出站 443");
}
else if (IsSslFailure(ex))
{
sb.Append("|排查:SSL/TLS 握手失败,常见原因为 " +
"①服务器无法访问外网(工控内网请改用\"跨网中转(Outbox)\"推送模式,由跳板机 AlertBridge 实际发送) " +
"②企业代理/防火墙做 SSL 拦截且其根证书未被本机信任 " +
"③系统时间不准导致证书校验失败 " +
"④Webhook 地址主机名或端口有误(非标准 HTTPS 端口)");
}
return sb.ToString();
}
/// <summary>
/// 是否为超时(TaskCanceledException 且非用户主动取消,或 TimeoutException
/// </summary>
private static bool IsTimeout(Exception ex)
{
for (var c = ex; c != null; c = c.InnerException)
{
if (c is TimeoutException) return true;
// HttpClient 超时表现为 TaskCanceledException,内部通常带 TimeoutException
if (c is TaskCanceledException && c.InnerException is TimeoutException) return true;
}
return false;
}
/// <summary>
/// 是否为 SSL/TLS 握手或证书校验失败
/// </summary>
private static bool IsSslFailure(Exception ex)
{
for (var c = ex; c != null; c = c.InnerException)
{
if (c is AuthenticationException) return true;
var msg = c.Message;
if (!string.IsNullOrEmpty(msg) &&
(msg.Contains("SSL", StringComparison.OrdinalIgnoreCase) ||
msg.Contains("TLS", StringComparison.OrdinalIgnoreCase) ||
msg.Contains("certificate", StringComparison.OrdinalIgnoreCase)))
{
return true;
}
}
return false;
}
}
}
+74
View File
@@ -17,40 +17,114 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DeviceCommand", "DeviceComm
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IOT_API", "IOT_API\IOT_API.csproj", "{1E2A7C73-2CAE-4084-939B-0F8EE73AA955}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AlertBridge", "AlertBridge\AlertBridge.csproj", "{C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Debug|x64.ActiveCfg = Debug|Any CPU
{6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Debug|x64.Build.0 = Debug|Any CPU
{6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Debug|x86.ActiveCfg = Debug|Any CPU
{6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Debug|x86.Build.0 = Debug|Any CPU
{6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Release|Any CPU.Build.0 = Release|Any CPU
{6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Release|x64.ActiveCfg = Release|Any CPU
{6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Release|x64.Build.0 = Release|Any CPU
{6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Release|x86.ActiveCfg = Release|Any CPU
{6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Release|x86.Build.0 = Release|Any CPU
{9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Debug|x64.ActiveCfg = Debug|Any CPU
{9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Debug|x64.Build.0 = Debug|Any CPU
{9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Debug|x86.ActiveCfg = Debug|Any CPU
{9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Debug|x86.Build.0 = Debug|Any CPU
{9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Release|Any CPU.Build.0 = Release|Any CPU
{9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Release|x64.ActiveCfg = Release|Any CPU
{9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Release|x64.Build.0 = Release|Any CPU
{9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Release|x86.ActiveCfg = Release|Any CPU
{9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Release|x86.Build.0 = Release|Any CPU
{4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Debug|x64.ActiveCfg = Debug|Any CPU
{4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Debug|x64.Build.0 = Debug|Any CPU
{4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Debug|x86.ActiveCfg = Debug|Any CPU
{4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Debug|x86.Build.0 = Debug|Any CPU
{4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Release|Any CPU.Build.0 = Release|Any CPU
{4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Release|x64.ActiveCfg = Release|Any CPU
{4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Release|x64.Build.0 = Release|Any CPU
{4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Release|x86.ActiveCfg = Release|Any CPU
{4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Release|x86.Build.0 = Release|Any CPU
{D8209B91-D7D0-444B-B569-D3FA74D191DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D8209B91-D7D0-444B-B569-D3FA74D191DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D8209B91-D7D0-444B-B569-D3FA74D191DD}.Debug|x64.ActiveCfg = Debug|Any CPU
{D8209B91-D7D0-444B-B569-D3FA74D191DD}.Debug|x64.Build.0 = Debug|Any CPU
{D8209B91-D7D0-444B-B569-D3FA74D191DD}.Debug|x86.ActiveCfg = Debug|Any CPU
{D8209B91-D7D0-444B-B569-D3FA74D191DD}.Debug|x86.Build.0 = Debug|Any CPU
{D8209B91-D7D0-444B-B569-D3FA74D191DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D8209B91-D7D0-444B-B569-D3FA74D191DD}.Release|Any CPU.Build.0 = Release|Any CPU
{D8209B91-D7D0-444B-B569-D3FA74D191DD}.Release|x64.ActiveCfg = Release|Any CPU
{D8209B91-D7D0-444B-B569-D3FA74D191DD}.Release|x64.Build.0 = Release|Any CPU
{D8209B91-D7D0-444B-B569-D3FA74D191DD}.Release|x86.ActiveCfg = Release|Any CPU
{D8209B91-D7D0-444B-B569-D3FA74D191DD}.Release|x86.Build.0 = Release|Any CPU
{C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Debug|x64.ActiveCfg = Debug|Any CPU
{C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Debug|x64.Build.0 = Debug|Any CPU
{C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Debug|x86.ActiveCfg = Debug|Any CPU
{C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Debug|x86.Build.0 = Debug|Any CPU
{C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Release|Any CPU.Build.0 = Release|Any CPU
{C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Release|x64.ActiveCfg = Release|Any CPU
{C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Release|x64.Build.0 = Release|Any CPU
{C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Release|x86.ActiveCfg = Release|Any CPU
{C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Release|x86.Build.0 = Release|Any CPU
{2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Debug|x64.ActiveCfg = Debug|Any CPU
{2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Debug|x64.Build.0 = Debug|Any CPU
{2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Debug|x86.ActiveCfg = Debug|Any CPU
{2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Debug|x86.Build.0 = Debug|Any CPU
{2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Release|Any CPU.Build.0 = Release|Any CPU
{2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Release|x64.ActiveCfg = Release|Any CPU
{2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Release|x64.Build.0 = Release|Any CPU
{2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Release|x86.ActiveCfg = Release|Any CPU
{2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Release|x86.Build.0 = Release|Any CPU
{1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Debug|x64.ActiveCfg = Debug|Any CPU
{1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Debug|x64.Build.0 = Debug|Any CPU
{1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Debug|x86.ActiveCfg = Debug|Any CPU
{1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Debug|x86.Build.0 = Debug|Any CPU
{1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Release|Any CPU.Build.0 = Release|Any CPU
{1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Release|x64.ActiveCfg = Release|Any CPU
{1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Release|x64.Build.0 = Release|Any CPU
{1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Release|x86.ActiveCfg = Release|Any CPU
{1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Release|x86.Build.0 = Release|Any CPU
{C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Debug|x64.ActiveCfg = Debug|Any CPU
{C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Debug|x64.Build.0 = Debug|Any CPU
{C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Debug|x86.ActiveCfg = Debug|Any CPU
{C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Debug|x86.Build.0 = Debug|Any CPU
{C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Release|Any CPU.Build.0 = Release|Any CPU
{C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Release|x64.ActiveCfg = Release|Any CPU
{C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Release|x64.Build.0 = Release|Any CPU
{C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Release|x86.ActiveCfg = Release|Any CPU
{C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+74 -2
View File
@@ -1,14 +1,86 @@
using Microsoft.AspNetCore.Mvc;
using Model;
using Model.Dto.Asset;
using Service.Interface;
using SqlSugar;
using WebAPI.Filters;
namespace WebAPI.Controllers
{
/// <summary>
/// 二维码
/// 设备二维码生成与打印(资产侧;生成/绑定 RFID 需 device:edit,列表/图片/打印页 device:view 可访问)
/// 扫码直达:二维码内容 = http://host/asset/ledger/equipment?id={equipmentId}
/// </summary>
[ApiController]
[Route("api/asset/qrcode")]
public class QrCodeController : ControllerBase
{
// TODO: 实现 二维码 相关接口
private readonly IQrCodeService _qrService;
public QrCodeController(IQrCodeService qrService)
{
_qrService = qrService;
}
// baseUrl 由当前请求构造(scheme://host[:port]);如需固定域名,改为读 sys_param site_base_url
private string BaseUrl => $"{Request.Scheme}://{Request.Host}";
/// <summary>生成二维码 URL(写入设备 QrCodeUrl 字段,不返回图片)</summary>
[HttpPost("generate/{equipmentId}")]
[RequirePermission("device:edit")]
public async Task<IActionResult> Generate(long equipmentId)
{
return Ok(await _qrService.GenerateAsync(equipmentId, BaseUrl));
}
/// <summary>生成二维码 PNG 图片(直接返回 image/png 二进制)</summary>
[HttpGet("image/{equipmentId}")]
[RequirePermission("device:view")]
public async Task<IActionResult> GetImage(long equipmentId)
{
var result = await _qrService.GenerateImageAsync(equipmentId, BaseUrl);
if (!result.IsSuccess || result.Data == null)
return Ok(Result<byte[]>.Error(result.Msg));
return File(result.Data, "image/png");
}
/// <summary>批量生成二维码 URL</summary>
[HttpPost("batch-generate")]
[RequirePermission("device:edit")]
public async Task<IActionResult> BatchGenerate([FromBody] long[] equipmentIds)
{
return Ok(await _qrService.BatchGenerateAsync(equipmentIds, BaseUrl));
}
/// <summary>设备二维码列表(分页,qrOnly=1 仅看已生成)</summary>
[HttpGet("list")]
[RequirePermission("device:view")]
public async Task<IActionResult> GetList(int pageIndex = 1, int pageSize = 10, string? keyword = null, [FromQuery] bool qrOnly = false)
{
RefAsync<int> total = 0;
var result = await _qrService.GetListPagedAsync(pageIndex, pageSize, total, keyword, qrOnly);
Response.Headers["X-Total-Count"] = total.Value.ToString();
return result.IsSuccess
? Ok(Result<List<QrCodeDto>>.Success(result.Data))
: Ok(Result<List<QrCodeDto>>.Error(result.Msg));
}
/// <summary>打印页(返回 text/html,浏览器打开后自动调起打印)</summary>
[HttpGet("print/{equipmentId}")]
[RequirePermission("device:view")]
public async Task<IActionResult> Print(long equipmentId)
{
var result = await _qrService.GetPrintHtmlAsync(equipmentId, BaseUrl);
if (!result.IsSuccess) return Ok(result);
return Content(result.Data!, "text/html; charset=utf-8");
}
/// <summary>绑定 RFID 编号到设备(query 传 rfidCode</summary>
[HttpPost("bind-rfid/{equipmentId}")]
[RequirePermission("device:edit")]
public async Task<IActionResult> BindRfid(long equipmentId, [FromQuery] string rfidCode)
{
return Ok(await _qrService.BindRfidAsync(equipmentId, rfidCode));
}
}
}
@@ -1,14 +1,95 @@
using Microsoft.AspNetCore.Mvc;
using Model;
using Model.Dto.Config;
using Service.Interface.Config;
using SqlSugar;
using WebAPI.Filters;
namespace WebAPI.Controllers
namespace WebAPI.Controllers.Config
{
/// <summary>
/// 网关管理
/// </summary>
[ApiController]
[Route("api/config/gateway")]
[RequirePermission("device:view")]
public class GatewayController : ControllerBase
{
// TODO: 实现 网关管理 相关接口
private readonly IGatewayService _gatewayService;
public GatewayController(IGatewayService gatewayService)
{
_gatewayService = gatewayService;
}
/// <summary>
/// 网关列表(分页)
/// </summary>
[HttpGet("list")]
public async Task<Result<List<GatewayDto>>> GetList([FromQuery] int pageIndex = 1, [FromQuery] int pageSize = 10,
[FromQuery] string? keyword = null, [FromQuery] int? protocolType = null)
{
var total = new RefAsync<int>();
var result = await _gatewayService.GetPagedAsync(pageIndex, pageSize, total, keyword, protocolType);
Response.Headers["X-Total-Count"] = total.Value.ToString();
return result;
}
/// <summary>
/// 网关详情
/// </summary>
[HttpGet("{id}")]
public async Task<Result<GatewayDto>> GetById(long id)
{
return await _gatewayService.GetByIdAsync(id);
}
/// <summary>
/// 新增网关(需 device:edit 权限)
/// </summary>
[HttpPost]
[RequirePermission("device:edit")]
public async Task<Result<GatewayDto>> Add([FromBody] GatewayDto dto)
{
return await _gatewayService.AddAsync(dto);
}
/// <summary>
/// 修改网关(需 device:edit 权限)
/// </summary>
[HttpPut]
[RequirePermission("device:edit")]
public async Task<Result<GatewayDto>> Update([FromBody] GatewayDto dto)
{
return await _gatewayService.UpdateAsync(dto);
}
/// <summary>
/// 删除网关(软删除,设备 GatewayId 置 0;需 device:edit 权限)
/// </summary>
[HttpDelete("{id}")]
[RequirePermission("device:edit")]
public async Task<Result> Delete(long id)
{
return await _gatewayService.DeleteAsync(id);
}
/// <summary>
/// 测试网关连接(TCP/串口尝试连接,更新在线状态)
/// </summary>
[HttpPost("{id}/test-connection")]
public async Task<Result<GatewayTestResultDto>> TestConnection(long id)
{
return await _gatewayService.TestConnectionAsync(id);
}
/// <summary>
/// 网关下拉选项(设备表单用)
/// </summary>
[HttpGet("options")]
public async Task<Result<List<GatewayOptionDto>>> GetOptions()
{
return await _gatewayService.GetOptionsAsync();
}
}
}
@@ -1,4 +1,10 @@
using Microsoft.AspNetCore.Mvc;
using Model;
using Model.Dto.Config;
using Service.Interface.Config;
using SqlSugar;
using System.Threading.Tasks;
using WebAPI.Filters;
namespace WebAPI.Controllers
{
@@ -7,8 +13,108 @@ namespace WebAPI.Controllers
/// </summary>
[ApiController]
[Route("api/config/device")]
[RequirePermission("device:view")]
public class IotDeviceController : ControllerBase
{
// TODO: 实现 IOT设备管理 相关接口
private readonly IDeviceService _deviceService;
private readonly IDeviceCommandService _deviceCommandService;
public IotDeviceController(IDeviceService deviceService, IDeviceCommandService deviceCommandService)
{
_deviceService = deviceService;
_deviceCommandService = deviceCommandService;
}
/// <summary>
/// 设备列表(分页查询,支持关键字搜索,可按所属产品筛选)
/// </summary>
/// <param name="pageIndex">页码(从1开始,默认1</param>
/// <param name="pageSize">每页数量(默认10</param>
/// <param name="keyword">关键字(模糊匹配设备编号/名称/类型)</param>
/// <param name="productId">所属产品Id0 表示不过滤)</param>
/// <param name="gatewayId">所属网关Id0 表示不过滤)</param>
[HttpGet("list")]
public async Task<IActionResult> GetList(int pageIndex = 1, int pageSize = 10, string? keyword = null, long productId = 0, long gatewayId = 0)
{
RefAsync<int> total = 0;
var result = await _deviceService.GetPagedAsync(pageIndex, pageSize, total, keyword, productId > 0 ? productId : null, gatewayId > 0 ? gatewayId : null);
Response.Headers["X-Total-Count"] = total.Value.ToString();
return result.IsSuccess
? Ok(Result<List<IotDeviceDto>>.Success(result.Data))
: Ok(Result<List<IotDeviceDto>>.Error(result.Msg));
}
/// <summary>
/// 设备详情
/// </summary>
/// <param name="id">设备主键 Id</param>
[HttpGet("{id}")]
public async Task<IActionResult> GetById(long id)
{
return Ok(await _deviceService.GetByIdAsync(id));
}
/// <summary>
/// 新增设备(需 device:edit 权限)
/// </summary>
[HttpPost]
[RequirePermission("device:edit")]
public async Task<IActionResult> Add([FromBody] IotDeviceDto dto)
{
return Ok(await _deviceService.AddAsync(dto));
}
/// <summary>
/// 修改设备(需 device:edit 权限)
/// </summary>
[HttpPut]
[RequirePermission("device:edit")]
public async Task<IActionResult> Update([FromBody] IotDeviceDto dto)
{
return Ok(await _deviceService.UpdateAsync(dto));
}
/// <summary>
/// 删除设备(软删除;需 device:edit 权限)
/// </summary>
/// <param name="id">设备主键 Id</param>
[HttpDelete("{id}")]
[RequirePermission("device:edit")]
public async Task<IActionResult> Delete(long id)
{
return Ok(await _deviceService.DeleteAsync(id));
}
/// <summary>
/// 设备日志(设备独享日志,分页查询)
/// </summary>
/// <param name="id">设备主键 Id</param>
/// <param name="pageIndex">页码(默认1</param>
/// <param name="pageSize">每页数量(默认20</param>
[HttpGet("{id}/logs")]
public async Task<IActionResult> GetLogs(long id, int pageIndex = 1, int pageSize = 20)
{
RefAsync<int> total = 0;
var result = await _deviceService.GetDeviceLogsAsync(id, pageIndex, pageSize, total);
Response.Headers["X-Total-Count"] = total.Value.ToString();
return result.IsSuccess
? Ok(Result<List<DeviceLogDto>>.Success(result.Data))
: Ok(Result<List<DeviceLogDto>>.Error(result.Msg));
}
/// <summary>
/// 按物模型可写点向设备下发指令(需 device:control 权限)
/// </summary>
/// <param name="id">设备主键 Id</param>
/// <param name="dto">指令请求(PointId 点Id + Value 工程值 + IsSimulated 是否模拟)</param>
[HttpPost("{id}/command")]
[RequirePermission("device:control")]
public async Task<IActionResult> SendCommand(long id, [FromBody] DeviceCommandDto dto)
{
if (dto == null)
return Ok(Result.Error("请求体不能为空"));
dto.Id = id.ToString();
return Ok(await _deviceCommandService.SendCommandAsync(dto));
}
}
}
+116 -2
View File
@@ -1,14 +1,128 @@
using Microsoft.AspNetCore.Mvc;
using Model;
using Model.Dto.Config;
using Service.Interface.Config;
using SqlSugar;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace WebAPI.Controllers
{
/// <summary>
/// 产品管理
/// 产品管理(含产品分类)
/// </summary>
[ApiController]
[Route("api/config/product")]
public class ProductController : ControllerBase
{
// TODO: 实现 产品管理 相关接口
private readonly IProductService _productService;
public ProductController(IProductService productService)
{
_productService = productService;
}
// ===================== 产品分类 =====================
/// <summary>
/// 产品分类列表
/// </summary>
[HttpGet("category/list")]
public async Task<IActionResult> GetCategories()
{
return Ok(await _productService.GetCategoriesAsync());
}
/// <summary>
/// 新增产品分类
/// </summary>
[HttpPost("category/add")]
public async Task<IActionResult> AddCategory([FromBody] ProductCategoryDto dto)
{
return Ok(await _productService.AddCategoryAsync(dto));
}
/// <summary>
/// 修改产品分类
/// </summary>
[HttpPut("category/update")]
public async Task<IActionResult> UpdateCategory([FromBody] ProductCategoryDto dto)
{
return Ok(await _productService.UpdateCategoryAsync(dto));
}
/// <summary>
/// 删除产品分类(软删除)
/// </summary>
[HttpDelete("category/{id}")]
public async Task<IActionResult> DeleteCategory(long id)
{
return Ok(await _productService.DeleteCategoryAsync(id));
}
// ===================== 产品 =====================
/// <summary>
/// 产品下拉选项(设备选择所属产品)
/// </summary>
[HttpGet("options")]
public async Task<IActionResult> GetOptions()
{
return Ok(await _productService.GetOptionsAsync());
}
/// <summary>
/// 产品列表(分页)
/// </summary>
/// <param name="pageIndex">页码(从1开始,默认1</param>
/// <param name="pageSize">每页数量(默认10</param>
/// <param name="keyword">关键字(模糊匹配型号/名称)</param>
/// <param name="categoryId">分类Id0 表示不过滤)</param>
[HttpGet("list")]
public async Task<IActionResult> GetList(int pageIndex = 1, int pageSize = 10, string? keyword = null, long categoryId = 0)
{
RefAsync<int> total = 0;
var result = await _productService.GetPagedAsync(pageIndex, pageSize, total, keyword, categoryId);
Response.Headers["X-Total-Count"] = total.Value.ToString();
return result.IsSuccess
? Ok(Result<List<ProductDto>>.Success(result.Data))
: Ok(Result<List<ProductDto>>.Error(result.Msg));
}
/// <summary>
/// 产品详情
/// </summary>
[HttpGet("{id}")]
public async Task<IActionResult> GetById(long id)
{
return Ok(await _productService.GetByIdAsync(id));
}
/// <summary>
/// 新增产品
/// </summary>
[HttpPost]
public async Task<IActionResult> Add([FromBody] ProductDto dto)
{
return Ok(await _productService.AddAsync(dto));
}
/// <summary>
/// 修改产品
/// </summary>
[HttpPut]
public async Task<IActionResult> Update([FromBody] ProductDto dto)
{
return Ok(await _productService.UpdateAsync(dto));
}
/// <summary>
/// 删除产品(软删除)
/// </summary>
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(long id)
{
return Ok(await _productService.DeleteAsync(id));
}
}
}
@@ -0,0 +1,72 @@
using Microsoft.AspNetCore.Mvc;
using Model;
using Model.Dto.Config;
using Service.Interface.Config;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace WebAPI.Controllers
{
/// <summary>
/// 物模型点(产品/设备通用,按 ownerType+ownerId 归属)
/// </summary>
[ApiController]
[Route("api/config/thing-point")]
public class ThingPointController : ControllerBase
{
private readonly IThingPointService _thingPointService;
public ThingPointController(IThingPointService thingPointService)
{
_thingPointService = thingPointService;
}
/// <summary>
/// 点列表
/// </summary>
/// <param name="ownerType">归属类型(1=产品 2=设备)</param>
/// <param name="ownerId">归属对象Id</param>
/// <param name="keyword">关键字(点编码/名称)</param>
[HttpGet("list")]
public async Task<IActionResult> GetList(int ownerType, long ownerId, string? keyword = null)
{
return Ok(await _thingPointService.GetListAsync(ownerType, ownerId, keyword));
}
/// <summary>
/// 点详情
/// </summary>
[HttpGet("{id}")]
public async Task<IActionResult> GetById(long id)
{
return Ok(await _thingPointService.GetByIdAsync(id));
}
/// <summary>
/// 新增点
/// </summary>
[HttpPost]
public async Task<IActionResult> Add([FromBody] ThingModelPointDto dto)
{
return Ok(await _thingPointService.AddAsync(dto));
}
/// <summary>
/// 修改点
/// </summary>
[HttpPut]
public async Task<IActionResult> Update([FromBody] ThingModelPointDto dto)
{
return Ok(await _thingPointService.UpdateAsync(dto));
}
/// <summary>
/// 删除点(软删除)
/// </summary>
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(long id)
{
return Ok(await _thingPointService.DeleteAsync(id));
}
}
}
@@ -1,14 +1,156 @@
using Microsoft.AspNetCore.Mvc;
using Model;
using Model.Dto.Inspection;
using Model.Entity.Inspection;
using Service.Interface;
using SqlSugar;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace WebAPI.Controllers
{
/// <summary>
/// 消息告警
/// 消息告警(告警列表与确认 / 告警统计分析 / 统一告警上报入口)
/// </summary>
[ApiController]
[Route("api/inspection/alert-message")]
public class AlertMessageController : ControllerBase
{
// TODO: 实现 消息告警 相关接口
private readonly IAlertMessageService _alertMessageService;
private readonly IAlertCenterService _alertCenterService;
public AlertMessageController(IAlertMessageService alertMessageService, IAlertCenterService alertCenterService)
{
_alertMessageService = alertMessageService;
_alertCenterService = alertCenterService;
}
/// <summary>
/// 告警列表(分页,支持级别/状态/类型/来源/设备/关键字/时间范围筛选;总记录数见响应头 X-Total-Count
/// </summary>
[HttpGet("list")]
public async Task<Result<List<AlertMessageDto>>> GetList(int pageIndex = 1, int pageSize = 10,
AlertLevelEnum? level = null, AlertStatusEnum? status = null, AlertTypeEnum? type = null,
AlertSourceEnum? source = null, string? deviceCode = null, string? keyword = null,
DateTime? startDate = null, DateTime? endDate = null)
{
RefAsync<int> total = 0;
var result = await _alertMessageService.GetPagedAsync(pageIndex, pageSize, total,
level, status, type, source, deviceCode, keyword, startDate, endDate);
Response.Headers["X-Total-Count"] = total.Value.ToString();
return result;
}
/// <summary>
/// 告警详情
/// </summary>
[HttpGet("{id}")]
public async Task<Result<AlertMessageDto>> GetById(long id)
{
return await _alertMessageService.GetByIdAsync(id);
}
/// <summary>
/// 告警上报统一入口(供其它模块/第三方系统跨进程调用;进程内模块请直接注入 IAlertCenterService
/// </summary>
[HttpPost("raise")]
public async Task<Result<string>> Raise([FromBody] AlertRaiseDto dto)
{
return await _alertCenterService.RaiseAsync(dto);
}
/// <summary>
/// 确认告警
/// </summary>
[HttpPost("{id}/ack")]
public async Task<Result<bool>> Acknowledge(long id, [FromBody] AlertOperateDto dto)
{
return await _alertMessageService.AcknowledgeAsync(id, dto);
}
/// <summary>
/// 处理告警
/// </summary>
[HttpPost("{id}/handle")]
public async Task<Result<bool>> Handle(long id, [FromBody] AlertOperateDto dto)
{
return await _alertMessageService.HandleAsync(id, dto);
}
/// <summary>
/// 关闭告警
/// </summary>
[HttpPost("{id}/close")]
public async Task<Result<bool>> Close(long id, [FromBody] AlertOperateDto dto)
{
return await _alertMessageService.CloseAsync(id, dto);
}
/// <summary>
/// 忽略告警(误报等场景)
/// </summary>
[HttpPost("{id}/ignore")]
public async Task<Result<bool>> Ignore(long id, [FromBody] AlertOperateDto dto)
{
return await _alertMessageService.IgnoreAsync(id, dto);
}
/// <summary>
/// 转工单(一期仅变更状态并记录工单Id,工单模块二期对接)
/// </summary>
[HttpPost("{id}/to-workorder")]
public async Task<Result<bool>> ToWorkOrder(long id, [FromBody] AlertOperateDto dto)
{
return await _alertMessageService.ToWorkOrderAsync(id, dto);
}
/// <summary>
/// 告警概览(未确认数/今日新增/今日紧急/近7日总数)
/// </summary>
[HttpGet("statistics/overview")]
public async Task<Result<AlertOverviewDto>> GetOverview()
{
return await _alertMessageService.GetOverviewAsync();
}
/// <summary>
/// 告警趋势统计(granularityday/week/month
/// </summary>
[HttpGet("statistics/trend")]
public async Task<Result<List<AlertTrendItemDto>>> GetTrend(string granularity = "day",
DateTime? startDate = null, DateTime? endDate = null)
{
return await _alertMessageService.GetTrendAsync(granularity, startDate, endDate);
}
/// <summary>
/// 告警分布统计(dimensiondevice/type/level/source
/// </summary>
[HttpGet("statistics/distribution")]
public async Task<Result<List<AlertDistributionItemDto>>> GetDistribution(string dimension = "level",
DateTime? startDate = null, DateTime? endDate = null)
{
return await _alertMessageService.GetDistributionAsync(dimension, startDate, endDate);
}
/// <summary>
/// 高频告警 TOP 排名
/// </summary>
[HttpGet("statistics/top")]
public async Task<Result<List<AlertTopItemDto>>> GetTop(int topN = 10,
DateTime? startDate = null, DateTime? endDate = null)
{
return await _alertMessageService.GetTopAsync(topN, startDate, endDate);
}
/// <summary>
/// 告警处理及时率统计
/// </summary>
[HttpGet("statistics/timeliness")]
public async Task<Result<AlertTimelinessDto>> GetTimeliness(DateTime? startDate = null, DateTime? endDate = null)
{
return await _alertMessageService.GetTimelinessAsync(startDate, endDate);
}
}
}
@@ -0,0 +1,196 @@
using Microsoft.AspNetCore.Mvc;
using Model;
using Model.Dto.Inspection;
using Service.Interface;
using SqlSugar;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace WebAPI.Controllers
{
/// <summary>
/// 告警通知配置(渠道管理 / 通知规则 / 推送日志 / 值班排班 / 跨网发件箱)
/// </summary>
[ApiController]
[Route("api/inspection/alert-notify")]
public class AlertNotifyController : ControllerBase
{
private readonly IAlertNotifyService _alertNotifyService;
public AlertNotifyController(IAlertNotifyService alertNotifyService)
{
_alertNotifyService = alertNotifyService;
}
#region
/// <summary>
/// 渠道列表(Secret 脱敏)
/// </summary>
[HttpGet("channel/list")]
public async Task<Result<List<AlertNotifyChannelDto>>> GetChannels()
{
return await _alertNotifyService.GetChannelsAsync();
}
/// <summary>
/// 新增渠道
/// </summary>
[HttpPost("channel")]
public async Task<Result<bool>> AddChannel([FromBody] AlertNotifyChannelDto dto)
{
return await _alertNotifyService.AddChannelAsync(dto);
}
/// <summary>
/// 修改渠道(Secret 传空表示保持原值)
/// </summary>
[HttpPut("channel/{id}")]
public async Task<Result<bool>> UpdateChannel(long id, [FromBody] AlertNotifyChannelDto dto)
{
dto.Id = id.ToString();
return await _alertNotifyService.UpdateChannelAsync(dto);
}
/// <summary>
/// 删除渠道(软删除)
/// </summary>
[HttpDelete("channel/{id}")]
public async Task<Result<bool>> DeleteChannel(long id)
{
return await _alertNotifyService.DeleteChannelAsync(id);
}
/// <summary>
/// 渠道测试推送(验证 Webhook 配置是否正确)
/// </summary>
[HttpPost("channel/{id}/test")]
public async Task<Result<bool>> TestChannel(long id)
{
return await _alertNotifyService.TestSendAsync(id);
}
#endregion
#region
/// <summary>
/// 通知规则列表
/// </summary>
[HttpGet("rule/list")]
public async Task<Result<List<AlertNotifyRuleDto>>> GetRules()
{
return await _alertNotifyService.GetRulesAsync();
}
/// <summary>
/// 新增通知规则
/// </summary>
[HttpPost("rule")]
public async Task<Result<bool>> AddRule([FromBody] AlertNotifyRuleDto dto)
{
return await _alertNotifyService.AddRuleAsync(dto);
}
/// <summary>
/// 修改通知规则
/// </summary>
[HttpPut("rule/{id}")]
public async Task<Result<bool>> UpdateRule(long id, [FromBody] AlertNotifyRuleDto dto)
{
dto.Id = id.ToString();
return await _alertNotifyService.UpdateRuleAsync(dto);
}
/// <summary>
/// 删除通知规则(软删除)
/// </summary>
[HttpDelete("rule/{id}")]
public async Task<Result<bool>> DeleteRule(long id)
{
return await _alertNotifyService.DeleteRuleAsync(id);
}
/// <summary>
/// 启用/停用通知规则
/// </summary>
[HttpPut("rule/{id}/enabled")]
public async Task<Result<bool>> SetRuleEnabled(long id, [FromQuery] bool enabled)
{
return await _alertNotifyService.SetRuleEnabledAsync(id, enabled);
}
#endregion
#region
/// <summary>
/// 推送日志分页(可按成功状态/渠道筛选;总记录数见响应头 X-Total-Count
/// </summary>
[HttpGet("log/list")]
public async Task<Result<List<AlertNotifyLogDto>>> GetLogs(int pageIndex = 1, int pageSize = 10,
bool? success = null, long channelId = 0)
{
RefAsync<int> total = 0;
var result = await _alertNotifyService.GetLogsPagedAsync(pageIndex, pageSize, total, success, channelId);
Response.Headers["X-Total-Count"] = total.Value.ToString();
return result;
}
#endregion
#region
/// <summary>
/// 值班排班列表(按日期范围,缺省本月)
/// </summary>
[HttpGet("duty/list")]
public async Task<Result<List<DutyRosterDto>>> GetDuties(DateTime? startDate = null, DateTime? endDate = null)
{
return await _alertNotifyService.GetDutiesAsync(startDate, endDate);
}
/// <summary>
/// 新增排班
/// </summary>
[HttpPost("duty")]
public async Task<Result<bool>> AddDuty([FromBody] DutyRosterDto dto)
{
return await _alertNotifyService.AddDutyAsync(dto);
}
/// <summary>
/// 修改排班
/// </summary>
[HttpPut("duty/{id}")]
public async Task<Result<bool>> UpdateDuty(long id, [FromBody] DutyRosterDto dto)
{
dto.Id = id.ToString();
return await _alertNotifyService.UpdateDutyAsync(dto);
}
/// <summary>
/// 删除排班(软删除)
/// </summary>
[HttpDelete("duty/{id}")]
public async Task<Result<bool>> DeleteDuty(long id)
{
return await _alertNotifyService.DeleteDutyAsync(id);
}
#endregion
#region AlertBridge 使
/// <summary>
/// 拉取待推送发件箱(AlertBridge 轮询调用;返回自包含推送报文)
/// </summary>
[HttpGet("outbox/pull")]
public async Task<Result<List<AlertOutboxItemDto>>> PullOutbox([FromQuery] int batch = 20)
{
return await _alertNotifyService.PullOutboxAsync(batch);
}
/// <summary>
/// 回写发件箱推送结果(AlertBridge 推送完成后调用)
/// </summary>
[HttpPost("outbox/{id}/result")]
public async Task<Result<bool>> ReportOutboxResult(long id, [FromBody] OutboxResultDto dto)
{
return await _alertNotifyService.ReportOutboxResultAsync(id, dto);
}
#endregion
}
}
@@ -1,14 +1,79 @@
using Microsoft.AspNetCore.Mvc;
using Model.Dto.Inspection;
using Service.Interface;
using System.Threading.Tasks;
namespace WebAPI.Controllers
{
/// <summary>
/// 告警规则
/// 告警规则(规则实例:查看所有设备告警的规则列表)
/// </summary>
[ApiController]
[Route("api/inspection/alert-rule")]
public class AlertRuleController : ControllerBase
{
// TODO: 实现 告警规则 相关接口
private readonly IAlertRuleService _alertRuleService;
public AlertRuleController(IAlertRuleService alertRuleService)
{
_alertRuleService = alertRuleService;
}
/// <summary>
/// 获取全部告警规则列表
/// </summary>
[HttpGet("list")]
public async Task<IActionResult> GetList()
{
return Ok(await _alertRuleService.GetListAsync());
}
/// <summary>
/// 获取某归属对象(产品/设备)下的规则列表
/// </summary>
/// <param name="ownerType">归属类型(1=产品 2=设备)</param>
/// <param name="ownerId">归属对象Id</param>
/// <param name="pointCode">可选:绑定点编码过滤</param>
[HttpGet("owner")]
public async Task<IActionResult> GetOwnerRules(int ownerType, long ownerId, string? pointCode = null)
{
return Ok(await _alertRuleService.GetOwnerRulesAsync(ownerType, ownerId, pointCode));
}
/// <summary>
/// 新增告警规则(前端传 DTO
/// </summary>
[HttpPost("add")]
public async Task<IActionResult> Add([FromBody] AlertRuleDto dto)
{
return Ok(await _alertRuleService.AddAsync(dto));
}
/// <summary>
/// 修改告警规则(前端传 DTO
/// </summary>
[HttpPut("update")]
public async Task<IActionResult> Update([FromBody] AlertRuleDto dto)
{
return Ok(await _alertRuleService.UpdateAsync(dto));
}
/// <summary>
/// 删除告警规则(软删除)
/// </summary>
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(long id)
{
return Ok(await _alertRuleService.DeleteAsync(id));
}
/// <summary>
/// 启用/停用告警规则
/// </summary>
[HttpPut("{id}/enabled")]
public async Task<IActionResult> SetEnabled(long id, [FromQuery] bool enabled)
{
return Ok(await _alertRuleService.SetEnabledAsync(id, enabled));
}
}
}
@@ -0,0 +1,67 @@
using Microsoft.AspNetCore.Mvc;
using Model.Dto.Inspection;
using Service.Interface;
using System.Threading.Tasks;
namespace WebAPI.Controllers
{
/// <summary>
/// 数据转发(配置数据转发规则,支持通过消息通知的方式定时转发数据)
/// </summary>
[ApiController]
[Route("api/inspection/data-forward")]
public class DataForwardController : ControllerBase
{
private readonly IDataForwardService _dataForwardService;
public DataForwardController(IDataForwardService dataForwardService)
{
_dataForwardService = dataForwardService;
}
/// <summary>
/// 获取全部转发规则列表
/// </summary>
[HttpGet("list")]
public async Task<IActionResult> GetList()
{
return Ok(await _dataForwardService.GetListAsync());
}
/// <summary>
/// 新增转发规则(前端传 DTO
/// </summary>
[HttpPost("add")]
public async Task<IActionResult> Add([FromBody] DataForwardRuleDto dto)
{
return Ok(await _dataForwardService.AddAsync(dto));
}
/// <summary>
/// 修改转发规则(前端传 DTO
/// </summary>
[HttpPut("update")]
public async Task<IActionResult> Update([FromBody] DataForwardRuleDto dto)
{
return Ok(await _dataForwardService.UpdateAsync(dto));
}
/// <summary>
/// 删除转发规则(软删除)
/// </summary>
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(long id)
{
return Ok(await _dataForwardService.DeleteAsync(id));
}
/// <summary>
/// 启用/停用转发规则
/// </summary>
[HttpPut("{id}/enabled")]
public async Task<IActionResult> SetEnabled(long id, [FromQuery] bool enabled)
{
return Ok(await _dataForwardService.SetEnabledAsync(id, enabled));
}
}
}
@@ -1,14 +1,114 @@
using Microsoft.AspNetCore.Mvc;
using Service.Interface;
using System.Threading.Tasks;
namespace WebAPI.Controllers
{
/// <summary>
/// 统计报表
/// 统计报表(设备统计/维护报表/维修报表)
/// </summary>
[ApiController]
[Route("api/inspection/report")]
public class StatisticsReportController : ControllerBase
{
// TODO: 实现 统计报表 相关接口
private readonly IStatisticsReportService _statisticsReportService;
public StatisticsReportController(IStatisticsReportService statisticsReportService)
{
_statisticsReportService = statisticsReportService;
}
#region
/// <summary>
/// 设备统计概览(总数/在用/闲置/报废等)
/// </summary>
[HttpGet("device/statistics")]
public async Task<IActionResult> GetDeviceStatistics()
{
return Ok(await _statisticsReportService.GetDeviceStatisticsAsync());
}
/// <summary>
/// 按分类统计设备数量
/// </summary>
[HttpGet("device/category")]
public async Task<IActionResult> GetCategoryStatistics()
{
return Ok(await _statisticsReportService.GetCategoryStatisticsAsync());
}
/// <summary>
/// 按部门统计设备数量
/// </summary>
[HttpGet("device/department")]
public async Task<IActionResult> GetDepartmentStatistics()
{
return Ok(await _statisticsReportService.GetDepartmentStatisticsAsync());
}
#endregion
#region
/// <summary>
/// 设备维护统计(校准/保养及时率、超期数量等)
/// </summary>
[HttpGet("maintenance/statistics")]
public async Task<IActionResult> GetMaintenanceStatistics()
{
return Ok(await _statisticsReportService.GetMaintenanceStatisticsAsync());
}
/// <summary>
/// 维护超期设备明细
/// </summary>
[HttpGet("maintenance/overdue")]
public async Task<IActionResult> GetMaintenanceOverdueDetails()
{
return Ok(await _statisticsReportService.GetMaintenanceOverdueDetailsAsync());
}
#endregion
#region
/// <summary>
/// 维修统计概览
/// </summary>
[HttpGet("repair/statistics")]
public async Task<IActionResult> GetRepairStatistics()
{
return Ok(await _statisticsReportService.GetRepairStatisticsAsync());
}
/// <summary>
/// 故障类型分布
/// </summary>
[HttpGet("repair/fault-type")]
public async Task<IActionResult> GetFaultTypeDistribution()
{
return Ok(await _statisticsReportService.GetFaultTypeDistributionAsync());
}
/// <summary>
/// 维修工时分析
/// </summary>
[HttpGet("repair/hours-analysis")]
public async Task<IActionResult> GetRepairHoursAnalysis()
{
return Ok(await _statisticsReportService.GetRepairHoursAnalysisAsync());
}
/// <summary>
/// 维修记录明细列表
/// </summary>
[HttpGet("repair/records")]
public async Task<IActionResult> GetRepairRecordDetails()
{
return Ok(await _statisticsReportService.GetRepairRecordDetailsAsync());
}
#endregion
}
}
+31 -2
View File
@@ -1,14 +1,43 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Model;
using Model.Dto.System;
using Service.Interface;
using SqlSugar;
using WebAPI.Filters;
namespace WebAPI.Controllers
{
/// <summary>
/// 操作审计日志
/// 操作审计日志(满足实验室审计要求;需 user:manage 权限)
/// </summary>
[ApiController]
[Route("api/system/audit")]
[RequirePermission("user:manage")]
public class AuditController : ControllerBase
{
// TODO: 实现 操作审计日志 相关接口
private readonly IAuditLogService _auditLogService;
public AuditController(IAuditLogService auditLogService)
{
_auditLogService = auditLogService;
}
/// <summary>
/// 审计日志列表(分页;筛选:操作人/描述关键字、操作类型、操作对象、时间段)
/// </summary>
[HttpGet("list")]
public async Task<IActionResult> GetList(int pageIndex = 1, int pageSize = 20,
string? keyword = null, string? operationType = null, string? operationTarget = null,
DateTime? startTime = null, DateTime? endTime = null)
{
RefAsync<int> total = 0;
var result = await _auditLogService.GetPagedAsync(pageIndex, pageSize, total,
keyword, operationType, operationTarget, startTime, endTime);
Response.Headers["X-Total-Count"] = total.Value.ToString();
return result.IsSuccess
? Ok(Result<List<AuditLogDto>>.Success(result.Data))
: Ok(Result<List<AuditLogDto>>.Error(result.Msg));
}
}
}
@@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Model;
using Model.Dto.System;
using Service.Interface;
using WebAPI.Filters;
namespace WebAPI.Controllers
{
/// <summary>
/// 认证授权(登录/登出/当前用户/改密)
/// </summary>
[ApiController]
[Route("api/system/auth")]
public class AuthController : ControllerBase
{
private readonly IAuthService _authService;
public AuthController(IAuthService authService)
{
_authService = authService;
}
/// <summary>
/// 登录(用户名+密码,成功返回 Token 与权限列表)
/// </summary>
[HttpPost("login")]
[AllowAnonymous]
public async Task<IActionResult> Login([FromBody] LoginDto dto)
{
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
return Ok(await _authService.LoginAsync(dto, ip));
}
/// <summary>
/// 退出登录(记录审计日志)
/// </summary>
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout()
{
return Ok(await _authService.LogoutAsync());
}
/// <summary>
/// 当前登录用户信息 + 权限列表
/// </summary>
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> Me()
{
return Ok(await _authService.GetMeAsync());
}
/// <summary>
/// 修改自己的密码
/// </summary>
[HttpPost("change-password")]
[Authorize]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordDto dto)
{
return Ok(await _authService.ChangePasswordAsync(dto));
}
}
}
+109 -2
View File
@@ -1,14 +1,121 @@
using Microsoft.AspNetCore.Mvc;
using Model;
using Model.Dto.System;
using Service.Interface;
using SqlSugar;
using WebAPI.Filters;
namespace WebAPI.Controllers
{
/// <summary>
/// 数据字典管理
/// 数据字典管理(管理侧需 system:manage 权限;业务侧 options 接口无权限供其它模块调用)
/// </summary>
[ApiController]
[Route("api/system/dict")]
public class DictController : ControllerBase
{
// TODO: 实现 数据字典管理 相关接口
private readonly IDictService _dictService;
public DictController(IDictService dictService)
{
_dictService = dictService;
}
// ==================== 字典分类 ====================
/// <summary>字典分类列表(分页)</summary>
[HttpGet("type/list")]
[RequirePermission("system:manage")]
public async Task<IActionResult> GetTypeList(int pageIndex = 1, int pageSize = 10, string? keyword = null)
{
RefAsync<int> total = 0;
var result = await _dictService.GetTypesPagedAsync(pageIndex, pageSize, total, keyword);
Response.Headers["X-Total-Count"] = total.Value.ToString();
return result.IsSuccess
? Ok(Result<List<DictTypeDto>>.Success(result.Data))
: Ok(Result<List<DictTypeDto>>.Error(result.Msg));
}
/// <summary>字典分类下拉(全部启用的分类)</summary>
[HttpGet("type/options")]
public async Task<IActionResult> GetTypeOptions()
{
return Ok(await _dictService.GetTypesAllAsync());
}
/// <summary>字典分类详情</summary>
[HttpGet("type/{id}")]
[RequirePermission("system:manage")]
public async Task<IActionResult> GetTypeById(long id)
{
return Ok(await _dictService.GetTypeByIdAsync(id));
}
/// <summary>新增字典分类</summary>
[HttpPost("type")]
[RequirePermission("system:manage")]
public async Task<IActionResult> AddType([FromBody] DictTypeDto dto)
{
return Ok(await _dictService.AddTypeAsync(dto));
}
/// <summary>修改字典分类</summary>
[HttpPut("type")]
[RequirePermission("system:manage")]
public async Task<IActionResult> UpdateType([FromBody] DictTypeDto dto)
{
return Ok(await _dictService.UpdateTypeAsync(dto));
}
/// <summary>删除字典分类(分类下有字典项时禁止删除)</summary>
[HttpDelete("type/{id}")]
[RequirePermission("system:manage")]
public async Task<IActionResult> DeleteType(long id)
{
return Ok(await _dictService.DeleteTypeAsync(id));
}
// ==================== 字典项 ====================
/// <summary>查询指定分类下的字典项</summary>
[HttpGet("item/list/{typeId}")]
[RequirePermission("system:manage")]
public async Task<IActionResult> GetItemsByType(long typeId)
{
return Ok(await _dictService.GetItemsByTypeAsync(typeId));
}
/// <summary>新增字典项</summary>
[HttpPost("item")]
[RequirePermission("system:manage")]
public async Task<IActionResult> AddItem([FromBody] DictItemDto dto)
{
return Ok(await _dictService.AddItemAsync(dto));
}
/// <summary>修改字典项</summary>
[HttpPut("item")]
[RequirePermission("system:manage")]
public async Task<IActionResult> UpdateItem([FromBody] DictItemDto dto)
{
return Ok(await _dictService.UpdateItemAsync(dto));
}
/// <summary>删除字典项</summary>
[HttpDelete("item/{id}")]
[RequirePermission("system:manage")]
public async Task<IActionResult> DeleteItem(long id)
{
return Ok(await _dictService.DeleteItemAsync(id));
}
// ==================== 业务侧(无权限,供其它模块通过字典编码查询) ====================
/// <summary>按字典编码查询启用项(带缓存,业务侧调用)</summary>
[HttpGet("options/{typeCode}")]
public async Task<IActionResult> GetOptions(string typeCode)
{
return Ok(await _dictService.GetOptionsByCodeAsync(typeCode));
}
}
}
@@ -1,14 +1,118 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Model;
using Model.Dto.System;
using Service.Interface;
using SqlSugar;
using WebAPI.Filters;
namespace WebAPI.Controllers
{
/// <summary>
/// 文件存储管理
/// 文件存储管理(存储配置 CRUD + 连接测试 + 文件上传 + 文件记录查询,需 system:manage 权限)
/// </summary>
[ApiController]
[Route("api/system/file-storage")]
[RequirePermission("system:manage")]
public class FileStorageController : ControllerBase
{
// TODO: 实现 文件存储管理 相关接口
private readonly IFileStorageService _storageService;
public FileStorageController(IFileStorageService storageService)
{
_storageService = storageService;
}
// ==================== 存储配置 ====================
/// <summary>存储配置列表(分页)</summary>
[HttpGet("list")]
public async Task<IActionResult> GetList(int pageIndex = 1, int pageSize = 10, string? keyword = null)
{
RefAsync<int> total = 0;
var result = await _storageService.GetListPagedAsync(pageIndex, pageSize, total, keyword);
Response.Headers["X-Total-Count"] = total.Value.ToString();
return result.IsSuccess
? Ok(Result<List<FileStorageConfigDto>>.Success(result.Data))
: Ok(Result<List<FileStorageConfigDto>>.Error(result.Msg));
}
/// <summary>存储配置详情</summary>
[HttpGet("{id}")]
public async Task<IActionResult> GetById(long id)
{
return Ok(await _storageService.GetByIdAsync(id));
}
/// <summary>新增存储配置</summary>
[HttpPost]
public async Task<IActionResult> Add([FromBody] FileStorageConfigDto dto)
{
return Ok(await _storageService.AddAsync(dto));
}
/// <summary>修改存储配置(AccessKey/SecretKey 为 **** 掩码时保留原值)</summary>
[HttpPut]
public async Task<IActionResult> Update([FromBody] FileStorageConfigDto dto)
{
return Ok(await _storageService.UpdateAsync(dto));
}
/// <summary>删除存储配置(默认通道禁止删除)</summary>
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(long id)
{
return Ok(await _storageService.DeleteAsync(id));
}
/// <summary>设为默认存储通道</summary>
[HttpPost("{id}/default")]
public async Task<IActionResult> SetDefault(long id)
{
return Ok(await _storageService.SetDefaultAsync(id));
}
/// <summary>测试连接(按配置调用对应 Provider 的 TestConnectionAsync</summary>
[HttpPost("{id}/test")]
public async Task<IActionResult> Test(long id)
{
return Ok(await _storageService.TestAsync(id));
}
// ==================== 文件上传/记录 ====================
/// <summary>上传文件(multipart/form-data,字段名 file;使用默认存储通道)</summary>
/// <param name="file">文件</param>
/// <param name="bizType">业务类型(equipment_attachment/qrcode/generic 等,可选)</param>
/// <param name="bizId">业务Id(可选)</param>
/// <param name="uploader">上传人(可选,默认取当前登录用户)</param>
[HttpPost("upload")]
[RequestSizeLimit(100 * 1024 * 1024)]
public async Task<IActionResult> Upload(IFormFile file, [FromForm] string? bizType = null, [FromForm] string? bizId = null, [FromForm] string? uploader = null)
{
if (file == null || file.Length == 0)
return Ok(Result<FileRecordDto>.Error("请选择要上传的文件"));
using var stream = file.OpenReadStream();
return Ok(await _storageService.UploadAsync(stream, file.FileName, file.Length, bizType, bizId, uploader));
}
/// <summary>文件记录列表(分页,可按关键字和业务类型过滤)</summary>
[HttpGet("file/list")]
public async Task<IActionResult> GetFileList(int pageIndex = 1, int pageSize = 10, string? keyword = null, string? bizType = null)
{
RefAsync<int> total = 0;
var result = await _storageService.GetFileListPagedAsync(pageIndex, pageSize, total, keyword, bizType);
Response.Headers["X-Total-Count"] = total.Value.ToString();
return result.IsSuccess
? Ok(Result<List<FileRecordDto>>.Success(result.Data))
: Ok(Result<List<FileRecordDto>>.Error(result.Msg));
}
/// <summary>文件记录详情</summary>
[HttpGet("file/{id}")]
public async Task<IActionResult> GetFileById(long id)
{
return Ok(await _storageService.GetFileByIdAsync(id));
}
}
}
@@ -1,14 +1,69 @@
using Microsoft.AspNetCore.Mvc;
using Model;
using Model.Dto.System;
using Service.Interface;
using WebAPI.Filters;
namespace WebAPI.Controllers
{
/// <summary>
/// 组织架构管理
/// 组织架构管理(公司/实验室/部门/班组树;需 user:manage 权限)
/// </summary>
[ApiController]
[Route("api/system/organization")]
[RequirePermission("user:manage")]
public class OrganizationController : ControllerBase
{
// TODO: 实现 组织架构管理 相关接口
private readonly IOrgService _orgService;
public OrganizationController(IOrgService orgService)
{
_orgService = orgService;
}
/// <summary>
/// 完整组织树(嵌套子级,含班组白/夜班)
/// </summary>
[HttpGet("tree")]
public async Task<IActionResult> GetTree()
{
return Ok(await _orgService.GetTreeAsync());
}
/// <summary>
/// 组织下拉选项(平铺)
/// </summary>
[HttpGet("options")]
public async Task<IActionResult> GetOptions()
{
return Ok(await _orgService.GetOptionsAsync());
}
/// <summary>
/// 新增组织节点(ParentId 传父级Id,根节点传 "0"
/// </summary>
[HttpPost]
public async Task<IActionResult> Add([FromBody] OrgDto dto)
{
return Ok(await _orgService.AddAsync(dto));
}
/// <summary>
/// 修改组织节点
/// </summary>
[HttpPut]
public async Task<IActionResult> Update([FromBody] OrgDto dto)
{
return Ok(await _orgService.UpdateAsync(dto));
}
/// <summary>
/// 删除组织节点(有子级或挂靠用户时不可删)
/// </summary>
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(long id)
{
return Ok(await _orgService.DeleteAsync(id));
}
}
}
+91 -2
View File
@@ -1,14 +1,103 @@
using Microsoft.AspNetCore.Mvc;
using Model;
using Model.Dto.System;
using Service.Interface;
using SqlSugar;
using WebAPI.Filters;
namespace WebAPI.Controllers
{
/// <summary>
/// 角色权限管理
/// 角色权限管理(需 user:manage 权限)
/// </summary>
[ApiController]
[Route("api/system/role")]
[RequirePermission("user:manage")]
public class RoleController : ControllerBase
{
// TODO: 实现 角色权限管理 相关接口
private readonly IRoleService _roleService;
public RoleController(IRoleService roleService)
{
_roleService = roleService;
}
/// <summary>
/// 角色列表(分页)
/// </summary>
[HttpGet("list")]
public async Task<IActionResult> GetList(int pageIndex = 1, int pageSize = 10, string? keyword = null)
{
RefAsync<int> total = 0;
var result = await _roleService.GetPagedAsync(pageIndex, pageSize, total, keyword);
Response.Headers["X-Total-Count"] = total.Value.ToString();
return result.IsSuccess
? Ok(Result<List<RoleDto>>.Success(result.Data))
: Ok(Result<List<RoleDto>>.Error(result.Msg));
}
/// <summary>
/// 角色下拉选项
/// </summary>
[HttpGet("options")]
public async Task<IActionResult> GetOptions()
{
return Ok(await _roleService.GetOptionsAsync());
}
/// <summary>
/// 全部权限列表(分配权限用)
/// </summary>
[HttpGet("permissions")]
public async Task<IActionResult> GetPermissions()
{
return Ok(await _roleService.GetPermissionsAsync());
}
/// <summary>
/// 角色详情(含权限Id列表)
/// </summary>
[HttpGet("{id}")]
public async Task<IActionResult> GetById(long id)
{
return Ok(await _roleService.GetByIdAsync(id));
}
/// <summary>
/// 新增角色(PermissionIds 传权限)
/// </summary>
[HttpPost]
public async Task<IActionResult> Add([FromBody] RoleDto dto)
{
return Ok(await _roleService.AddAsync(dto));
}
/// <summary>
/// 修改角色(PermissionIds 传 null 不改权限)
/// </summary>
[HttpPut]
public async Task<IActionResult> Update([FromBody] RoleDto dto)
{
return Ok(await _roleService.UpdateAsync(dto));
}
/// <summary>
/// 删除角色(系统内置角色不可删)
/// </summary>
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(long id)
{
return Ok(await _roleService.DeleteAsync(id));
}
/// <summary>
/// 分配权限(全量覆盖)
/// </summary>
[HttpPost("{id}/permissions")]
public async Task<IActionResult> AssignPermissions(long id, [FromBody] List<string> permissionIds)
{
var ids = permissionIds?.Select(long.Parse).Where(p => p > 0).ToList() ?? new List<long>();
return Ok(await _roleService.AssignPermissionsAsync(id, ids));
}
}
}
@@ -1,14 +1,95 @@
using Microsoft.AspNetCore.Mvc;
using Model;
using Model.Dto.System;
using Service.Interface;
using SqlSugar;
using WebAPI.Filters;
namespace WebAPI.Controllers
{
/// <summary>
/// 系统参数配置
/// 系统参数配置(管理侧需 system:manage 权限;业务侧 value/values 接口无权限供其它模块调用)
/// </summary>
[ApiController]
[Route("api/system/param")]
public class SystemParamController : ControllerBase
{
// TODO: 实现 系统参数配置 相关接口
private readonly ISystemParamService _paramService;
public SystemParamController(ISystemParamService paramService)
{
_paramService = paramService;
}
// ==================== 管理侧 ====================
/// <summary>参数列表(分页,可按关键字和分组过滤)</summary>
[HttpGet("list")]
[RequirePermission("system:manage")]
public async Task<IActionResult> GetList(int pageIndex = 1, int pageSize = 10, string? keyword = null, string? group = null)
{
RefAsync<int> total = 0;
var result = await _paramService.GetListPagedAsync(pageIndex, pageSize, total, keyword, group);
Response.Headers["X-Total-Count"] = total.Value.ToString();
return result.IsSuccess
? Ok(Result<List<SystemParamDto>>.Success(result.Data))
: Ok(Result<List<SystemParamDto>>.Error(result.Msg));
}
/// <summary>参数详情</summary>
[HttpGet("{id}")]
[RequirePermission("system:manage")]
public async Task<IActionResult> GetById(long id)
{
return Ok(await _paramService.GetByIdAsync(id));
}
/// <summary>新增参数</summary>
[HttpPost]
[RequirePermission("system:manage")]
public async Task<IActionResult> Add([FromBody] SystemParamDto dto)
{
return Ok(await _paramService.AddAsync(dto));
}
/// <summary>修改参数(内置参数仅允许改 Value/Remark</summary>
[HttpPut]
[RequirePermission("system:manage")]
public async Task<IActionResult> Update([FromBody] SystemParamDto dto)
{
return Ok(await _paramService.UpdateAsync(dto));
}
/// <summary>删除参数(内置参数禁止删除)</summary>
[HttpDelete("{id}")]
[RequirePermission("system:manage")]
public async Task<IActionResult> Delete(long id)
{
return Ok(await _paramService.DeleteAsync(id));
}
/// <summary>清空参数缓存(运维用)</summary>
[HttpPost("cache/reload")]
[RequirePermission("system:manage")]
public async Task<IActionResult> ReloadCache()
{
return Ok(await _paramService.ReloadCacheAsync());
}
// ==================== 业务侧(无权限,供其它模块按 Key 取值) ====================
/// <summary>按 Key 取单个值</summary>
[HttpGet("value/{key}")]
public async Task<IActionResult> GetValue(string key)
{
return Ok(await _paramService.GetValueAsync(key));
}
/// <summary>按 Key 列表批量取值(query: ?keys=k1&amp;k2&amp;k3</summary>
[HttpGet("values")]
public async Task<IActionResult> GetValues([FromQuery] string[] keys)
{
return Ok(await _paramService.GetValuesAsync(keys));
}
}
}
+112 -2
View File
@@ -1,14 +1,124 @@
using Microsoft.AspNetCore.Mvc;
using Model;
using Model.Dto.System;
using Service.Interface;
using SqlSugar;
using WebAPI.Filters;
namespace WebAPI.Controllers
{
/// <summary>
/// 用户管理
/// 用户管理(需 user:manage 权限)
/// </summary>
[ApiController]
[Route("api/system/user")]
[RequirePermission("user:manage")]
public class UserController : ControllerBase
{
// TODO: 实现 用户管理 相关接口
private readonly IUserService _userService;
public UserController(IUserService userService)
{
_userService = userService;
}
/// <summary>
/// 用户列表(分页,关键字匹配用户名/姓名,可按角色、组织节点筛选;受数据范围约束)
/// </summary>
[HttpGet("list")]
public async Task<IActionResult> GetList(int pageIndex = 1, int pageSize = 10, string? keyword = null, long roleId = 0, long orgId = 0)
{
RefAsync<int> total = 0;
var result = await _userService.GetPagedAsync(pageIndex, pageSize, total, keyword, roleId > 0 ? roleId : null, orgId > 0 ? orgId : null);
Response.Headers["X-Total-Count"] = total.Value.ToString();
return result.IsSuccess
? Ok(Result<List<UserDto>>.Success(result.Data))
: Ok(Result<List<UserDto>>.Error(result.Msg));
}
/// <summary>
/// 用户下拉选项(启用中的用户)
/// </summary>
[HttpGet("options")]
public async Task<IActionResult> GetOptions()
{
return Ok(await _userService.GetOptionsAsync());
}
/// <summary>
/// 用户详情
/// </summary>
[HttpGet("{id}")]
public async Task<IActionResult> GetById(long id)
{
return Ok(await _userService.GetByIdAsync(id));
}
/// <summary>
/// 新增用户(InitialPassword 传初始密码,RoleIds 传角色)
/// </summary>
[HttpPost]
public async Task<IActionResult> Add([FromBody] UserDto dto)
{
return Ok(await _userService.AddAsync(dto));
}
/// <summary>
/// 修改用户(RoleIds 传 null 不改角色;密码走重置接口)
/// </summary>
[HttpPut]
public async Task<IActionResult> Update([FromBody] UserDto dto)
{
return Ok(await _userService.UpdateAsync(dto));
}
/// <summary>
/// 删除用户(软删除)
/// </summary>
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(long id)
{
return Ok(await _userService.DeleteAsync(id));
}
/// <summary>
/// 分配角色(全量覆盖)
/// </summary>
[HttpPost("{id}/roles")]
public async Task<IActionResult> AssignRoles(long id, [FromBody] List<string> roleIds)
{
var ids = roleIds?.Select(long.Parse).Where(r => r > 0).ToList() ?? new List<long>();
return Ok(await _userService.AssignRolesAsync(id, ids));
}
/// <summary>
/// 重置用户密码
/// </summary>
[HttpPost("{id}/reset-password")]
public async Task<IActionResult> ResetPassword(long id, [FromBody] ResetPasswordRequest request)
{
if (request == null || string.IsNullOrWhiteSpace(request.NewPassword))
return Ok(Result.Error("新密码不能为空"));
return Ok(await _userService.ResetPasswordAsync(id, request.NewPassword));
}
/// <summary>
/// 启用/禁用用户
/// </summary>
[HttpPost("{id}/enabled")]
public async Task<IActionResult> SetEnabled(long id, [FromBody] EnabledRequest request)
{
return Ok(await _userService.SetEnabledAsync(id, request?.Enabled ?? true));
}
public class ResetPasswordRequest
{
public string NewPassword { get; set; } = "";
}
public class EnabledRequest
{
public bool Enabled { get; set; } = true;
}
}
}
+41
View File
@@ -1,7 +1,11 @@
using ORM;
using Service.Implement;
using Service.Interface;
using SqlSugar;
using System.Net.Http;
using System.Net.Security;
using System.Reflection;
using System.Security.Authentication;
namespace WebAPI
{
@@ -50,7 +54,44 @@ namespace WebAPI
// 泛型基础服务(IBaseService<> -> BaseService<>)单独注册
services.AddScoped(typeof(Service.Interface.IBaseService<>), typeof(Service.Implement.BaseService<>));
// 文件存储 Provider:命名约定 XxxFileStorageProvider 不匹配自动注册,手动登记
// 新增 MinIO/OSS 实现时在此追加一行 services.AddScoped<IFileStorageProvider, MinioFileStorageProvider>()
services.AddScoped<Service.Interface.IFileStorageProvider, Service.Implement.LocalFileStorageProvider>();
return services;
}
/// <summary>
/// 注册告警通知组件(飞书/钉钉/企微 NotifierTyped HttpClient 模式)
/// 新增渠道:实现 IAlertNotifier 后在此追加一行注册即可,调用方零改动
/// </summary>
public static IServiceCollection AddAlertNotification(this IServiceCollection services)
{
ConfigureNotifier(services.AddHttpClient<IAlertNotifier, FeishuNotifier>());
ConfigureNotifier(services.AddHttpClient<IAlertNotifier, DingTalkNotifier>());
ConfigureNotifier(services.AddHttpClient<IAlertNotifier, WeComNotifier>());
return services;
}
/// <summary>
/// 通知渠道 HttpClient 统一配置:
/// - 超时 15s(默认 100s 过长,测试推送遇不可达地址会长时间无响应)
/// - 显式启用 TLS1.2/1.3(规避个别环境默认协议协商失败导致的 SSL 握手异常)
/// - 连接池生命周期 5 分钟(长驻服务定期回收连接,避免陈旧连接/DNS 漂移引发握手失败)
/// </summary>
private static void ConfigureNotifier(IHttpClientBuilder builder)
{
builder
.ConfigureHttpClient(c => c.Timeout = TimeSpan.FromSeconds(15))
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
SslOptions = new SslClientAuthenticationOptions
{
EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
},
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2)
});
}
}
}
@@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Model;
namespace WebAPI.Filters
{
/// <summary>
/// 权限校验过滤器:标注在 Controller 或 Action 上,执行前校验当前用户(JWT Claims)是否拥有指定权限
/// 用法:[RequirePermission("device:edit")]
/// 权限编码在登录时随 JWT 写入 Claims,此处只读 Claims,不查库
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public class RequirePermissionAttribute : Attribute, IAsyncAuthorizationFilter
{
private readonly string _permissionCode;
public RequirePermissionAttribute(string permissionCode)
{
_permissionCode = permissionCode;
}
public Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
// 已被 [AllowAnonymous] 标记的接口放行(如登录本身)
if (context.ActionDescriptor.EndpointMetadata.Any(m => m is IAllowAnonymous))
return Task.CompletedTask;
var user = context.HttpContext.User;
if (user?.Identity?.IsAuthenticated != true)
{
context.Result = new UnauthorizedObjectResult(Result.Error("未登录或 Token 已失效"));
return Task.CompletedTask;
}
var perms = user.FindFirst("permissions")?.Value;
var permList = string.IsNullOrWhiteSpace(perms)
? new List<string>()
: perms.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
if (!permList.Contains(_permissionCode) && !permList.Contains("*"))
{
context.Result = new ObjectResult(Result.Error($"没有操作权限(需要权限:{_permissionCode}"))
{
StatusCode = StatusCodes.Status403Forbidden
};
}
return Task.CompletedTask;
}
}
}
+1
View File
@@ -8,6 +8,7 @@
<ItemGroup>
<PackageReference Include="AutoMapper" Version="16.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
+48 -1
View File
@@ -1,5 +1,10 @@
using Common;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using ORM;
using Service.Implement;
using Service.Interface;
using System.Text;
using System.Text.Json;
using WebAPI.Services;
@@ -31,7 +36,18 @@ namespace WebAPI
DatabaseConfig.SetTenant(tenantId);
DatabaseConfig.CreateDatabaseAndCheckConnection(createDatabase: true, checkConnection: true);
SqlSugarContext.InitDatabase();
builder.Services.AddControllers()
// RBAC 数据种子:预定义角色/权限/默认管理员(幂等)
DataSeeder.Seed();
// 飞书自建应用凭证:用于告警 @ 时把接收人手机号转成 open_id(webhook 机器人自身无此能力)
var feishu = builder.Configuration.GetSection("Feishu");
Common.Notify.FeishuConfig.Init(feishu["AppId"] ?? "", feishu["AppSecret"] ?? "");
builder.Services.AddControllers(options =>
{
// 非空字符串属性(如 Remark)缺失时不报 400,前端可以只传部分字段
options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;
})
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
@@ -43,6 +59,35 @@ namespace WebAPI
// 自动注册业务服务(Service.Interface -> Service.Implement
builder.Services.AddBusinessServices();
// 内存缓存:数据字典等查询频繁的基础数据按 typeCode 缓存,增删改时失效
builder.Services.AddMemoryCache();
// 当前用户上下文(从 JWT Claims 解析,供业务服务取操作人/做权限判断)
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
// JWT 认证:登录后签发 Token,后续请求凭 Bearer Token 识别用户与角色
var jwt = builder.Configuration.GetSection("Jwt");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwt["Issuer"],
ValidAudience = jwt["Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt["SecretKey"]!)),
ClockSkew = TimeSpan.FromSeconds(30)
};
});
// 告警通知渠道(飞书/钉钉/企微 Notifier+ 后台推送 Worker
builder.Services.AddAlertNotification();
builder.Services.AddHostedService<AlertNotifyWorker>();
// 温度箱模拟数据:每秒生成 100 条温湿度入库(真实设备接入后删除)
builder.Services.AddHostedService<TemperatureBoxSimulator>();
@@ -62,6 +107,8 @@ namespace WebAPI
// 导致前端 vite 代理被 CORS 拦截(Network Error);上位机/现场部署通常也只用 http
// app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
// 静态文件:设备附件上传后通过 /uploads/... 访问(存储于 wwwroot
+230
View File
@@ -0,0 +1,230 @@
using Model.Entity.Inspection;
using ORM;
using Service.Implement;
using Service.Interface;
using SqlSugar;
namespace WebAPI.Services
{
/// <summary>
/// 告警通知后台 Worker:消费 AlertNotifyBus 队列,按通知规则分发到飞书/钉钉/企微渠道
/// 流程:加载告警 → 匹配启用规则(按级别) → 静默期判断 → 直连推送(重试3次)或写跨网发件箱 → 记推送日志
/// </summary>
public class AlertNotifyWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<AlertNotifyWorker> _logger;
/// <summary>
/// 直连推送最大尝试次数(含首次)
/// </summary>
private const int MaxAttempts = 3;
public AlertNotifyWorker(IServiceScopeFactory scopeFactory, ILogger<AlertNotifyWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("告警通知 Worker 已启动");
try
{
await foreach (var evt in AlertNotifyBus.Reader.ReadAllAsync(stoppingToken))
{
try
{
await ProcessEventAsync(evt, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "处理告警通知事件失败, AlertId={AlertId}", evt.AlertId);
}
}
}
catch (OperationCanceledException)
{
// 正常停机
}
}
/// <summary>
/// 处理单个告警通知事件
/// </summary>
private async Task ProcessEventAsync(AlertNotifyEvent evt, CancellationToken ct)
{
var db = SqlSugarContext.DbContext;
// 1. 加载告警消息
var alert = await db.Queryable<AlertMessageEntity>()
.Where(x => x.Id == evt.AlertId && x.IsDel == 0)
.FirstAsync();
if (alert == null) return;
// 2. 匹配启用的通知规则(按告警级别)
var rules = await db.Queryable<AlertNotifyRuleEntity>()
.Where(x => x.IsDel == 0 && x.IsEnabled && x.AlertLevel == alert.AlertLevel)
.ToListAsync();
if (rules.Count == 0) return;
// 3. 每个事件独立创建作用域(Worker 是单例,服务是 Scoped
using var scope = _scopeFactory.CreateScope();
var notifyService = scope.ServiceProvider.GetRequiredService<IAlertNotifyService>();
var notifiers = scope.ServiceProvider.GetServices<IAlertNotifier>().ToList();
foreach (var rule in rules)
{
var channel = await db.Queryable<AlertNotifyChannelEntity>()
.Where(x => x.Id == rule.ChannelId && x.IsDel == 0 && x.IsEnabled)
.FirstAsync();
if (channel == null) continue;
// 4. 静默期判断:合并触发的重复告警在静默期内不重复推送(新告警必推)
if (!evt.IsNewAlert && rule.SilenceMinutes > 0)
{
var silenceStart = DateTime.Now.AddMinutes(-rule.SilenceMinutes);
bool recentlyPushed = await db.Queryable<AlertNotifyLogEntity>()
.AnyAsync(x => x.AlertId == alert.Id && x.ChannelId == channel.Id
&& x.Success && x.NotifyTime >= silenceStart);
if (recentlyPushed) continue;
}
// 5. 组装消息与 @ 列表(规则接收人 + 当班值班人电话)
var message = BuildMessage(alert);
var atMobiles = new List<string>();
if (!string.IsNullOrWhiteSpace(rule.Receivers))
{
// 接收人常带 @ 前缀(如 "@18872509102"),但企微 mentioned_mobile_list / 钉钉 atMobiles 只认纯手机号,
// 带 @ 的非法值无法匹配群成员导致 @ 不生效,这里统一去掉前导 @ 与空白
atMobiles.AddRange(rule.Receivers.Split(',', '', ';', '')
.Select(s => s.Trim().TrimStart('@').Trim()).Where(s => s.Length > 0));
}
if (rule.NotifyDutyPerson)
{
var duty = await notifyService.GetDutyByDateAsync(alert.LastAlertTime.Date);
if (duty != null && !string.IsNullOrWhiteSpace(duty.Phone))
{
atMobiles.Add(duty.Phone.Trim());
message.Content += $"\n值班人: {duty.PersonName}";
}
}
atMobiles = atMobiles.Distinct().ToList();
// 飞书渠道:at 标签只认 open_id,不认手机号——把接收人里的手机号转成 open_id
// "all" 与 ou_ 开头的原样保留)。转换在内网主站完成,直连与跨网(Outbox)两条路径都拿到 open_idAlertBridge 无需改。
if (channel.ChannelType == NotifyChannelTypeEnum.Feishu && atMobiles.Count > 0)
{
var feishuContact = scope.ServiceProvider.GetRequiredService<IFeishuContactService>();
var openIds = new List<string>();
foreach (var at in atMobiles)
{
if (at.Equals("all", StringComparison.OrdinalIgnoreCase) || at.StartsWith("ou_"))
{
openIds.Add(at);
}
else
{
var openId = await feishuContact.GetOpenIdByMobileAsync(at);
if (!string.IsNullOrEmpty(openId)) openIds.Add(openId);
else _logger.LogWarning("飞书接收人 {Receiver} 未换取到 open_id,已跳过 @(检查飞书应用权限与通讯录范围)", at);
}
}
atMobiles = openIds.Distinct().ToList();
}
// 关键:@ 列表必须回填到 message——直连模式的 Notifier 是从 message.AtMobiles 读取的。
// 此前只把 atMobiles 传给了 Outbox 分支,直连 SendAsync(message) 的 message.AtMobiles 始终为空,导致企微/钉钉不 @ 任何人。
message.AtMobiles = atMobiles;
// 6. 按渠道推送模式分发
if (channel.PushMode == NotifyPushModeEnum.Outbox)
{
// 跨网模式:写发件箱,由跳板机 AlertBridge 拉取推送并回写结果
await notifyService.EnqueueOutboxAsync(alert.Id, channel.Id, message, atMobiles);
_logger.LogInformation("告警 {AlertId} 已转入跨网发件箱(渠道 {Channel}", alert.Id, channel.Name);
continue;
}
// 直连模式:查找对应 Notifier,失败重试
var notifier = notifiers.FirstOrDefault(n => n.ChannelType == channel.ChannelType);
if (notifier == null)
{
await notifyService.WriteLogAsync(alert.Id, channel.Id, message.Title,
false, $"暂不支持的渠道类型: {channel.ChannelType}", 0);
continue;
}
NotifyResult result = NotifyResult.Fail("未执行");
int attempt = 0;
while (attempt < MaxAttempts && !ct.IsCancellationRequested)
{
attempt++;
result = await notifier.SendAsync(message, channel.WebhookUrl, channel.Secret, ct);
if (result.Success) break;
if (attempt < MaxAttempts)
{
await Task.Delay(TimeSpan.FromSeconds(attempt * 2), ct); // 2s/4s 退避
}
}
await notifyService.WriteLogAsync(alert.Id, channel.Id,
message.Title + " " + Truncate(message.Content, 800),
result.Success, result.ErrorMsg, attempt - 1);
if (!result.Success)
{
_logger.LogWarning("告警 {AlertId} 推送渠道 {Channel} 失败: {Error}", alert.Id, channel.Name, result.ErrorMsg);
}
}
}
/// <summary>
/// 按内置模板构建通知消息(标题 + markdown 正文;后续可由通知模板模块接管)
/// </summary>
private static AlertNotifyMessage BuildMessage(AlertMessageEntity alert)
{
string levelLabel = AlertLevelMap.ToLabel(alert.AlertLevel);
var content = new System.Text.StringBuilder();
content.AppendLine($"- 设备: {alert.DeviceName}({alert.DeviceCode})");
if (!string.IsNullOrWhiteSpace(alert.DeviceType)) content.AppendLine($"- 类型: {alert.DeviceType}");
if (!string.IsNullOrWhiteSpace(alert.Metric)) content.AppendLine($"- 测点: {alert.Metric}");
if (alert.CurrentValue.HasValue) content.AppendLine($"- 当前值: {alert.CurrentValue.Value}");
if (alert.ThresholdValue.HasValue) content.AppendLine($"- 阈值: {alert.ThresholdValue.Value}");
content.AppendLine($"- 告警类型: {TypeLabel(alert.AlertType)}");
content.AppendLine($"- 来源: {SourceLabel(alert.Source)}");
content.AppendLine($"- 内容: {alert.Content}");
if (alert.TriggerCount > 1) content.AppendLine($"- 累计触发: {alert.TriggerCount} 次");
content.Append($"- 时间: {alert.LastAlertTime:yyyy-MM-dd HH:mm:ss}");
return new AlertNotifyMessage
{
Title = $"【{levelLabel}】设备告警 - {alert.DeviceName ?? alert.DeviceCode}",
Content = content.ToString()
};
}
private static string TypeLabel(AlertTypeEnum type) => type switch
{
AlertTypeEnum.ThresholdExceed => "阈值超限",
AlertTypeEnum.DeviceOffline => "设备离线",
AlertTypeEnum.CommFault => "通讯故障",
AlertTypeEnum.PatrolAbnormal => "巡检异常",
AlertTypeEnum.Custom => "自定义",
_ => "其它"
};
private static string SourceLabel(AlertSourceEnum source) => source switch
{
AlertSourceEnum.RuleEngine => "规则引擎",
AlertSourceEnum.AutoPatrol => "自动巡检",
AlertSourceEnum.Dashboard => "数据看板",
AlertSourceEnum.Collection => "数据采集",
AlertSourceEnum.Manual => "手动上报",
_ => "其它"
};
private static string Truncate(string s, int max) =>
string.IsNullOrEmpty(s) || s.Length <= max ? s : s.Substring(0, max) + "...";
}
}
+62
View File
@@ -0,0 +1,62 @@
using System.Security.Claims;
using Service.Interface;
namespace WebAPI.Services
{
/// <summary>
/// 当前登录用户上下文实现:从 JWT ClaimsHttpContext.User)解析
/// </summary>
public class CurrentUser : ICurrentUser
{
private readonly IHttpContextAccessor _accessor;
public CurrentUser(IHttpContextAccessor accessor)
{
_accessor = accessor;
}
private ClaimsPrincipal? Principal => _accessor?.HttpContext?.User;
public bool IsAuthenticated => Principal?.Identity?.IsAuthenticated ?? false;
public long UserId
{
get
{
var v = Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return long.TryParse(v, out var id) ? id : 0;
}
}
public string UserName => Principal?.FindFirst(ClaimTypes.Name)?.Value ?? "";
public List<long> RoleIds => ParseList("roleIds").Select(long.Parse).ToList();
public List<string> RoleNames => ParseList("roleNames");
public List<string> Permissions => ParseList("permissions");
public byte DataScope
{
get
{
var v = Principal?.FindFirst("dataScope")?.Value;
return byte.TryParse(v, out var b) ? b : (byte)2;
}
}
public bool HasPermission(string code)
{
if (!IsAuthenticated) return false;
var perms = Permissions;
return perms.Contains(code) || perms.Contains("*");
}
private List<string> ParseList(string claimType)
{
var raw = Principal?.FindFirst(claimType)?.Value;
if (string.IsNullOrWhiteSpace(raw)) return new List<string>();
return raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
}
}
}
+34 -1
View File
@@ -1,5 +1,7 @@
using Model.Dto.Inspection;
using Model.Entity.Inspection;
using ORM;
using Service.Interface;
namespace WebAPI.Services
{
@@ -22,9 +24,11 @@ namespace WebAPI.Services
}
private readonly Dictionary<string, BoxState> _states = new();
private readonly IServiceScopeFactory _scopeFactory;
public TemperatureBoxSimulator()
public TemperatureBoxSimulator(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
for (int i = 1; i <= DeviceCount; i++)
{
_states[$"BOX-{i:D3}"] = new BoxState();
@@ -39,6 +43,7 @@ namespace WebAPI.Services
{
var now = DateTime.Now;
var batch = new List<TemperatureBoxDataEntity>(DeviceCount);
var alarmDevices = new List<(string Code, double Temp)>();
foreach (var kv in _states)
{
@@ -48,6 +53,10 @@ namespace WebAPI.Services
s.Humidity = Math.Clamp(s.Humidity + (Rnd.NextDouble() - 0.5) * 3, 20, 90);
bool alarm = s.Temperature >= AlarmTemp;
if (alarm)
{
alarmDevices.Add((kv.Key, Math.Round(s.Temperature, 1)));
}
batch.Add(new TemperatureBoxDataEntity
{
DeviceCode = kv.Key,
@@ -62,6 +71,30 @@ namespace WebAPI.Services
// 批量插入
await SqlSugarContext.DbContext.Insertable(batch).ExecuteCommandAsync();
// 报警设备上报告警中心(告警中心内部有 5 分钟合并窗口,不会每秒重复建告警)
if (alarmDevices.Count > 0)
{
// 单例 HostedService 不能直接注入 Scoped 服务,按批次创建作用域解析
using var scope = _scopeFactory.CreateScope();
var alertCenter = scope.ServiceProvider.GetRequiredService<IAlertCenterService>();
foreach (var (code, temp) in alarmDevices)
{
await alertCenter.RaiseAsync(new AlertRaiseDto
{
Source = AlertSourceEnum.Collection,
DeviceCode = code,
DeviceName = $"温度箱 {code}",
DeviceType = "TemperatureBox",
Metric = "Temperature",
AlertType = AlertTypeEnum.ThresholdExceed,
AlertLevel = AlertLevelEnum.Urgent,
CurrentValue = temp,
ThresholdValue = AlarmTemp,
Content = $"温度箱 {code} 温度超限:当前 {temp}℃ ≥ 报警阈值 {AlarmTemp}℃"
});
}
}
}
catch (Exception ex)
{
+6
View File
@@ -49,6 +49,12 @@ namespace Model.Dto.Asset
/// <summary>二维码编号(一物一码)</summary>
public string? QrCode { get; set; }
/// <summary>二维码扫码直达 URL(生成后写入,扫码跳转设备台账详情)</summary>
public string? QrCodeUrl { get; set; }
/// <summary>RFID 编号(绑定时写入)</summary>
public string? RfidCode { get; set; }
/// <summary>设备描述</summary>
public string? Description { get; set; }
+38
View File
@@ -0,0 +1,38 @@
namespace Model.Dto.Asset
{
/// <summary>
/// 设备二维码信息(生成/列表/详情共用)
/// </summary>
public class QrCodeDto
{
/// <summary>设备 Idlong → string</summary>
public string EquipmentId { get; set; }
/// <summary>设备编号</summary>
public string? EquipmentCode { get; set; }
/// <summary>设备名称</summary>
public string? EquipmentName { get; set; }
/// <summary>存放位置(打印标签用)</summary>
public string? Location { get; set; }
/// <summary>二维码编号(一物一码,人工可读)</summary>
public string? QrCode { get; set; }
/// <summary>扫码直达 URL(生成后写入;为空表示尚未生成)</summary>
public string? QrCodeUrl { get; set; }
/// <summary>RFID 编号(已绑定则有值)</summary>
public string? RfidCode { get; set; }
/// <summary>是否已生成二维码(QrCodeUrl 非空)</summary>
public bool HasGenerated => !string.IsNullOrWhiteSpace(QrCodeUrl);
}
/// <summary>
/// 批量生成结果
/// </summary>
public class QrCodeBatchResultDto
{
public string EquipmentId { get; set; }
public string? EquipmentCode { get; set; }
public string? EquipmentName { get; set; }
public string? QrCodeUrl { get; set; }
public bool Success { get; set; }
public string? Message { get; set; }
}
}
+28
View File
@@ -0,0 +1,28 @@
namespace Model.Dto.Config
{
/// <summary>
/// 设备指令下发请求 DTO
/// </summary>
public class DeviceCommandDto
{
/// <summary>
/// 设备主键 Idstring
/// </summary>
public string Id { get; set; }
/// <summary>
/// 物模型点 Id(string,决定写哪个寄存器/线圈)
/// </summary>
public string PointId { get; set; }
/// <summary>
/// 下发的工程值
/// </summary>
public double Value { get; set; }
/// <summary>
/// 是否仅记录(模拟/离线调试:不实际发网络报文)
/// </summary>
public bool IsSimulated { get; set; }
}
}
+32
View File
@@ -0,0 +1,32 @@
namespace Model.Dto.Config
{
/// <summary>
/// 设备日志 DTOId 由 long 改为 string,避免前端精度丢失)
/// </summary>
public class DeviceLogDto
{
/// <summary>主键 Idlong → string</summary>
public string Id { get; set; }
/// <summary>设备Id</summary>
public string DeviceId { get; set; }
/// <summary>设备编号</summary>
public string DeviceCode { get; set; }
/// <summary>日志级别(Info/Warn/Error</summary>
public string Level { get; set; }
/// <summary>日志类型(连接/通讯/采集/指令/系统)</summary>
public string LogType { get; set; }
/// <summary>日志内容</summary>
public string Message { get; set; }
/// <summary>记录时间</summary>
public DateTime LogTime { get; set; }
/// <summary>创建时间</summary>
public DateTime? CreateTime { get; set; }
}
}
+56
View File
@@ -0,0 +1,56 @@
namespace Model.Dto.Config
{
/// <summary>
/// 网关 DTO
/// </summary>
public class GatewayDto
{
public string Id { get; set; }
public string Code { get; set; }
public string Name { get; set; }
public int ProtocolType { get; set; }
// TCP
public string? Host { get; set; }
public int? Port { get; set; }
// 串口
public string? ComPort { get; set; }
public int? BaudRate { get; set; }
public byte? DataBits { get; set; }
public byte? StopBits { get; set; }
public byte? Parity { get; set; }
// S7
public string? CpuType { get; set; }
public byte? Rack { get; set; }
public byte? Slot { get; set; }
// 状态
public bool IsEnabled { get; set; }
public byte OnlineStatus { get; set; }
public DateTime? LastConnectedTime { get; set; }
public string? LastError { get; set; }
public string? Remark { get; set; }
public DateTime? CreateTime { get; set; }
}
/// <summary>
/// 网关下拉选项 DTO(设备表单选用)
/// </summary>
public class GatewayOptionDto
{
public string Id { get; set; }
public string Code { get; set; }
public string Name { get; set; }
}
/// <summary>
/// 测试连接结果 DTO
/// </summary>
public class GatewayTestResultDto
{
public bool Success { get; set; }
public string Message { get; set; }
}
}
+64
View File
@@ -0,0 +1,64 @@
using Model.Entity.Config;
namespace Model.Dto.Config
{
/// <summary>
/// IOT 设备 DTOId 由 long 改为 string,避免前端精度丢失)
/// </summary>
public class IotDeviceDto
{
/// <summary>主键 Idlong → string</summary>
public string Id { get; set; }
/// <summary>设备编号</summary>
public string Code { get; set; }
/// <summary>设备名称</summary>
public string Name { get; set; }
/// <summary>设备类型(型号,对应 DeviceCommand 驱动类)</summary>
public string DeviceType { get; set; }
/// <summary>所属产品Idstring0=未分类)</summary>
public string ProductId { get; set; }
/// <summary>所属产品型号/名称(只读展示)</summary>
public string? ProductName { get; set; }
/// <summary>所属网关Idstring0=未分配)</summary>
public string GatewayId { get; set; }
/// <summary>所属网关名称(只读展示)</summary>
public string? GatewayName { get; set; }
/// <summary>从站地址</summary>
public byte SlaveId { get; set; }
/// <summary>制造商</summary>
public string? Manufacturer { get; set; }
/// <summary>设备描述</summary>
public string? Description { get; set; }
/// <summary>通讯协议类型</summary>
public IotDeviceProtocolEnum ProtocolType { get; set; }
/// <summary>是否启用采集</summary>
public bool IsEnabled { get; set; } = true;
/// <summary>在线状态</summary>
public IotDeviceOnlineStatusEnum OnlineStatus { get; set; }
/// <summary>最后采集时间</summary>
public DateTime? LastCollectTime { get; set; }
/// <summary>最近错误信息</summary>
public string? LastError { get; set; }
/// <summary>备注</summary>
public string? Remark { get; set; }
/// <summary>创建时间</summary>
public DateTime? CreateTime { get; set; }
}
}
+26
View File
@@ -0,0 +1,26 @@
namespace Model.Dto.Config
{
/// <summary>
/// 产品分类 DTOId 由 long 改为 string,避免前端精度丢失)
/// </summary>
public class ProductCategoryDto
{
/// <summary>主键 Idlong → string</summary>
public string Id { get; set; }
/// <summary>分类编码</summary>
public string Code { get; set; }
/// <summary>分类名称</summary>
public string? Name { get; set; }
/// <summary>排序号</summary>
public int Sort { get; set; }
/// <summary>备注</summary>
public string? Remark { get; set; }
/// <summary>创建时间</summary>
public DateTime? CreateTime { get; set; }
}
}
+46
View File
@@ -0,0 +1,46 @@
using Model.Entity.Config;
namespace Model.Dto.Config
{
/// <summary>
/// 产品 DTOId 由 long 改为 string,避免前端精度丢失)
/// </summary>
public class ProductDto
{
/// <summary>主键 Idlong → string</summary>
public string Id { get; set; }
/// <summary>型号(唯一)</summary>
public string Model { get; set; }
/// <summary>产品名称</summary>
public string? Name { get; set; }
/// <summary>产品分类Id</summary>
public string CategoryId { get; set; }
/// <summary>分类名称(只读展示)</summary>
public string? CategoryName { get; set; }
/// <summary>制造商</summary>
public string? Manufacturer { get; set; }
/// <summary>产品描述</summary>
public string? Description { get; set; }
/// <summary>通讯协议</summary>
public IotDeviceProtocolEnum ProtocolType { get; set; }
/// <summary>消息模型</summary>
public string? MessageModel { get; set; }
/// <summary>是否启用</summary>
public bool IsEnabled { get; set; } = true;
/// <summary>备注</summary>
public string? Remark { get; set; }
/// <summary>创建时间</summary>
public DateTime? CreateTime { get; set; }
}
}
+17
View File
@@ -0,0 +1,17 @@
namespace Model.Dto.Config
{
/// <summary>
/// 产品下拉选项 DTO(设备选择"所属产品"、筛选下拉用)
/// </summary>
public class ProductOptionDto
{
/// <summary>主键 Idstring</summary>
public string Id { get; set; }
/// <summary>型号</summary>
public string Model { get; set; }
/// <summary>产品名称</summary>
public string? Name { get; set; }
}
}
+58
View File
@@ -0,0 +1,58 @@
using Model.Entity.Config;
namespace Model.Dto.Config
{
/// <summary>
/// 物模型点 DTOId 由 long 改为 string,避免前端精度丢失)
/// </summary>
public class ThingModelPointDto
{
/// <summary>主键 Idlong → string</summary>
public string Id { get; set; }
/// <summary>归属类型(1=产品 2=设备)</summary>
public ThingOwnerTypeEnum OwnerType { get; set; }
/// <summary>归属对象Idstring</summary>
public string OwnerId { get; set; }
/// <summary>点编码</summary>
public string Code { get; set; }
/// <summary>点名称</summary>
public string? Name { get; set; }
/// <summary>寄存器类型</summary>
public ThingRegisterTypeEnum RegisterType { get; set; }
/// <summary>读写权限</summary>
public ThingRwEnum Rw { get; set; }
/// <summary>数据类型</summary>
public ThingDataTypeEnum DataType { get; set; }
/// <summary>寄存器起始地址</summary>
public ushort Address { get; set; }
/// <summary>换算系数</summary>
public double Scale { get; set; } = 1;
/// <summary>偏移量</summary>
public double Offset { get; set; }
/// <summary>单位</summary>
public string? Unit { get; set; }
/// <summary>是否启用</summary>
public bool Enabled { get; set; } = true;
/// <summary>排序号</summary>
public int Sort { get; set; }
/// <summary>备注</summary>
public string? Remark { get; set; }
/// <summary>创建时间</summary>
public DateTime? CreateTime { get; set; }
}
}
+82
View File
@@ -0,0 +1,82 @@
using Model.Entity.Inspection;
namespace Model.Dto.Inspection
{
/// <summary>
/// 告警消息 DTOId 转 string 防前端精度丢失)
/// </summary>
public class AlertMessageDto
{
/// <summary>主键 Id</summary>
public string? Id { get; set; }
/// <summary>创建时间</summary>
public DateTime? CreateTime { get; set; }
/// <summary>告警来源</summary>
public AlertSourceEnum Source { get; set; }
/// <summary>关联告警规则Id"0"表示无)</summary>
public string? RuleId { get; set; }
/// <summary>设备编号</summary>
public string? DeviceCode { get; set; }
/// <summary>设备名称</summary>
public string? DeviceName { get; set; }
/// <summary>设备类型</summary>
public string? DeviceType { get; set; }
/// <summary>测点/监测指标</summary>
public string? Metric { get; set; }
/// <summary>告警类型</summary>
public AlertTypeEnum AlertType { get; set; }
/// <summary>告警级别(1信息/2警告/3紧急)</summary>
public AlertLevelEnum AlertLevel { get; set; }
/// <summary>当前值</summary>
public double? CurrentValue { get; set; }
/// <summary>阈值</summary>
public double? ThresholdValue { get; set; }
/// <summary>告警内容描述</summary>
public string? Content { get; set; }
/// <summary>通知模板编码(预留)</summary>
public string? TemplateCode { get; set; }
/// <summary>触发次数(合并窗口内累计)</summary>
public int TriggerCount { get; set; }
/// <summary>首次告警时间</summary>
public DateTime FirstAlertTime { get; set; }
/// <summary>最近告警时间</summary>
public DateTime LastAlertTime { get; set; }
/// <summary>告警状态(1未确认/2已确认/3已处理/4已关闭/5已忽略/6已转工单)</summary>
public AlertStatusEnum AlertStatus { get; set; }
/// <summary>确认人</summary>
public string? AckBy { get; set; }
/// <summary>确认时间</summary>
public DateTime? AckTime { get; set; }
/// <summary>处理人</summary>
public string? HandleBy { get; set; }
/// <summary>处理时间</summary>
public DateTime? HandleTime { get; set; }
/// <summary>处理备注</summary>
public string? HandleRemark { get; set; }
/// <summary>关联工单Id(预留二期,"0"表示未转工单)</summary>
public string? WorkOrderId { get; set; }
}
}
+136
View File
@@ -0,0 +1,136 @@
using Model.Entity.Inspection;
namespace Model.Dto.Inspection
{
/// <summary>
/// 通知渠道 DTOId 转 string;Secret 出口脱敏为是否已配置)
/// </summary>
public class AlertNotifyChannelDto
{
/// <summary>主键 Id</summary>
public string? Id { get; set; }
/// <summary>创建时间</summary>
public DateTime? CreateTime { get; set; }
/// <summary>渠道类型(1飞书/2钉钉/3企微)</summary>
public NotifyChannelTypeEnum ChannelType { get; set; }
/// <summary>渠道名称</summary>
public string? Name { get; set; }
/// <summary>Webhook 地址</summary>
public string? WebhookUrl { get; set; }
/// <summary>加签密钥(入参可传;出口仅返回脱敏标记,不回传明文)</summary>
public string? Secret { get; set; }
/// <summary>是否已配置加签密钥(出口专用)</summary>
public bool HasSecret { get; set; }
/// <summary>推送模式(1直连/2Outbox跨网中转)</summary>
public NotifyPushModeEnum PushMode { get; set; }
/// <summary>是否启用</summary>
public bool IsEnabled { get; set; }
/// <summary>备注</summary>
public string? Remark { get; set; }
}
/// <summary>
/// 通知规则 DTO
/// </summary>
public class AlertNotifyRuleDto
{
/// <summary>主键 Id</summary>
public string? Id { get; set; }
/// <summary>创建时间</summary>
public DateTime? CreateTime { get; set; }
/// <summary>规则名称</summary>
public string? RuleName { get; set; }
/// <summary>适用告警级别(1信息/2警告/3紧急)</summary>
public AlertLevelEnum AlertLevel { get; set; }
/// <summary>通知渠道Id</summary>
public string? ChannelId { get; set; }
/// <summary>接收人(@手机号/账号,逗号分隔)</summary>
public string? Receivers { get; set; }
/// <summary>是否通知当班值班人</summary>
public bool NotifyDutyPerson { get; set; }
/// <summary>静默期(分钟)</summary>
public int SilenceMinutes { get; set; }
/// <summary>是否启用</summary>
public bool IsEnabled { get; set; }
/// <summary>备注</summary>
public string? Remark { get; set; }
}
/// <summary>
/// 推送日志 DTO
/// </summary>
public class AlertNotifyLogDto
{
/// <summary>主键 Id</summary>
public string? Id { get; set; }
/// <summary>告警消息Id"0"表示渠道测试推送)</summary>
public string? AlertId { get; set; }
/// <summary>渠道类型</summary>
public NotifyChannelTypeEnum ChannelType { get; set; }
/// <summary>渠道Id</summary>
public string? ChannelId { get; set; }
/// <summary>推送目标(脱敏)</summary>
public string? Target { get; set; }
/// <summary>推送内容摘要</summary>
public string? Content { get; set; }
/// <summary>是否成功</summary>
public bool Success { get; set; }
/// <summary>错误信息</summary>
public string? ErrorMsg { get; set; }
/// <summary>重试次数</summary>
public int RetryCount { get; set; }
/// <summary>推送时间</summary>
public DateTime NotifyTime { get; set; }
}
/// <summary>
/// 值班排班 DTO
/// </summary>
public class DutyRosterDto
{
/// <summary>主键 Id</summary>
public string? Id { get; set; }
/// <summary>创建时间</summary>
public DateTime? CreateTime { get; set; }
/// <summary>值班日期</summary>
public DateTime DutyDate { get; set; }
/// <summary>值班人姓名</summary>
public string? PersonName { get; set; }
/// <summary>联系电话</summary>
public string? Phone { get; set; }
/// <summary>备注</summary>
public string? Remark { get; set; }
}
}
+59
View File
@@ -0,0 +1,59 @@
namespace Model.Dto.Inspection
{
/// <summary>
/// 跨网发件箱拉取结果 DTO(AlertBridge 拉取到的待推送项;Payload 自包含全部推送要素)
/// </summary>
public class AlertOutboxItemDto
{
/// <summary>发件箱 Id(回写结果时使用)</summary>
public string? Id { get; set; }
/// <summary>告警消息Id</summary>
public string? AlertId { get; set; }
/// <summary>渠道Id</summary>
public string? ChannelId { get; set; }
/// <summary>完整推送报文 JSONAlertOutboxPayload 序列化)</summary>
public string? Payload { get; set; }
/// <summary>重试次数</summary>
public int RetryCount { get; set; }
}
/// <summary>
/// 发件箱推送报文(自包含:AlertBridge 无需访问数据库/配置即可推送)
/// </summary>
public class AlertOutboxPayload
{
/// <summary>渠道类型(1飞书/2钉钉/3企微)</summary>
public int ChannelType { get; set; }
/// <summary>Webhook 地址</summary>
public string? WebhookUrl { get; set; }
/// <summary>加签密钥(可空)</summary>
public string? Secret { get; set; }
/// <summary>消息标题</summary>
public string? Title { get; set; }
/// <summary>消息正文(markdown</summary>
public string? Content { get; set; }
/// <summary>@ 手机号列表</summary>
public List<string>? AtMobiles { get; set; }
}
/// <summary>
/// 发件箱结果回写 DTOAlertBridge 推送完成后回写)
/// </summary>
public class OutboxResultDto
{
/// <summary>是否成功</summary>
public bool Success { get; set; }
/// <summary>结果信息(失败原因)</summary>
public string? ResultMsg { get; set; }
}
}
+62
View File
@@ -0,0 +1,62 @@
using Model.Entity.Inspection;
namespace Model.Dto.Inspection
{
/// <summary>
/// 告警上报 DTO(其它模块调用告警中心的统一入参:IAlertCenterService.RaiseAsync / POST raise
/// 除设备标识外全部可空,降低调用方接入成本
/// </summary>
public class AlertRaiseDto
{
/// <summary>告警来源(调用方标识自身模块)</summary>
public AlertSourceEnum Source { get; set; } = AlertSourceEnum.Other;
/// <summary>关联告警规则Id(来源为规则引擎时传,可空/0 表示无)</summary>
public long RuleId { get; set; }
/// <summary>设备编号(建议必传,用于去重合并)</summary>
public string? DeviceCode { get; set; }
/// <summary>设备名称</summary>
public string? DeviceName { get; set; }
/// <summary>设备类型(如 TemperatureBox</summary>
public string? DeviceType { get; set; }
/// <summary>测点/监测指标(如 Temperature</summary>
public string? Metric { get; set; }
/// <summary>告警类型(默认阈值超限)</summary>
public AlertTypeEnum AlertType { get; set; } = AlertTypeEnum.ThresholdExceed;
/// <summary>告警级别(1信息/2警告/3紧急)</summary>
public AlertLevelEnum AlertLevel { get; set; } = AlertLevelEnum.Warning;
/// <summary>当前值</summary>
public double? CurrentValue { get; set; }
/// <summary>阈值</summary>
public double? ThresholdValue { get; set; }
/// <summary>告警内容描述(空则由告警中心按内置模板生成)</summary>
public string? Content { get; set; }
/// <summary>通知模板编码(预留对接通知模板模块)</summary>
public string? TemplateCode { get; set; }
}
/// <summary>
/// 告警操作 DTO(确认/处理/关闭/忽略/转工单 共用入参)
/// </summary>
public class AlertOperateDto
{
/// <summary>操作人</summary>
public string? Operator { get; set; }
/// <summary>备注(处理说明/忽略原因等)</summary>
public string? Remark { get; set; }
/// <summary>工单Id(仅转工单时使用,二期工单模块创建后回填,可空)</summary>
public long WorkOrderId { get; set; }
}
}
+56
View File
@@ -0,0 +1,56 @@
namespace Model.Dto.Inspection
{
/// <summary>
/// 告警规则 DTOId 由 long 改为 string,避免前端精度丢失)
/// </summary>
public class AlertRuleDto
{
/// <summary>主键 Idlong → string</summary>
public string Id { get; set; }
/// <summary>归属类型(0=全局;1=产品 2=设备)</summary>
public byte OwnerType { get; set; }
/// <summary>归属对象Id0 表示全局)</summary>
public long OwnerId { get; set; }
/// <summary>绑定点编码(空表示整产品/设备级规则)</summary>
public string PointCode { get; set; }
/// <summary>删除状态(0、未删除;1、已删除)</summary>
public byte IsDel { get; set; }
/// <summary>创建时间</summary>
public DateTime? CreateTime { get; set; }
/// <summary>规则名称</summary>
public string RuleName { get; set; }
/// <summary>适用设备类型(空表示全部设备)</summary>
public string DeviceType { get; set; }
/// <summary>监测指标(Temperature / Humidity 等)</summary>
public string Metric { get; set; }
/// <summary>比较运算符(> >= < <= = ≠)</summary>
public string Operator { get; set; }
/// <summary>阈值</summary>
public double? Threshold { get; set; }
/// <summary>告警级别(信息/警告/紧急)</summary>
public string AlarmLevel { get; set; }
/// <summary>是否启用</summary>
public bool Enabled { get; set; }
/// <summary>触发次数</summary>
public int TriggerCount { get; set; }
/// <summary>最近触发时间</summary>
public DateTime? LastAlarmTime { get; set; }
/// <summary>备注</summary>
public string Remark { get; set; }
}
}
+101
View File
@@ -0,0 +1,101 @@
namespace Model.Dto.Inspection
{
/// <summary>
/// 告警趋势统计项(按日/周/月分桶)
/// </summary>
public class AlertTrendItemDto
{
/// <summary>时间桶(日:yyyy-MM-dd / 周:yyyy-Www / 月:yyyy-MM</summary>
public string Bucket { get; set; }
/// <summary>告警总数</summary>
public int Total { get; set; }
/// <summary>信息级数量</summary>
public int InfoCount { get; set; }
/// <summary>警告级数量</summary>
public int WarningCount { get; set; }
/// <summary>紧急级数量</summary>
public int UrgentCount { get; set; }
/// <summary>未闭环数量(未确认+已确认未处理)</summary>
public int UnhandledCount { get; set; }
}
/// <summary>
/// 告警分布统计项(按设备/类型/级别/来源维度)
/// </summary>
public class AlertDistributionItemDto
{
/// <summary>维度名称</summary>
public string Name { get; set; }
/// <summary>数量</summary>
public int Count { get; set; }
}
/// <summary>
/// 高频告警 TOP 排名项
/// </summary>
public class AlertTopItemDto
{
/// <summary>设备编号</summary>
public string? DeviceCode { get; set; }
/// <summary>设备名称</summary>
public string? DeviceName { get; set; }
/// <summary>告警次数(含合并累计 TriggerCount</summary>
public int Count { get; set; }
/// <summary>最近告警时间</summary>
public DateTime LastAlertTime { get; set; }
}
/// <summary>
/// 告警处理及时率统计
/// </summary>
public class AlertTimelinessDto
{
/// <summary>统计范围内告警总数</summary>
public int Total { get; set; }
/// <summary>已确认数(含已处理/已关闭)</summary>
public int AckCount { get; set; }
/// <summary>已处理/已关闭数</summary>
public int HandledCount { get; set; }
/// <summary>未闭环数</summary>
public int UnhandledCount { get; set; }
/// <summary>平均确认时长(分钟,无确认记录为 null</summary>
public double? AvgAckMinutes { get; set; }
/// <summary>平均处理时长(分钟,无处理记录为 null</summary>
public double? AvgHandleMinutes { get; set; }
/// <summary>及时率(%):30 分钟内确认的占比</summary>
public double TimelyRate { get; set; }
}
/// <summary>
/// 告警概览统计(列表页顶部卡片)
/// </summary>
public class AlertOverviewDto
{
/// <summary>未确认数量</summary>
public int UnacknowledgedCount { get; set; }
/// <summary>今日新增数量</summary>
public int TodayCount { get; set; }
/// <summary>今日紧急数量</summary>
public int TodayUrgentCount { get; set; }
/// <summary>近7日总数</summary>
public int WeekCount { get; set; }
}
}
@@ -0,0 +1,41 @@
namespace Model.Dto.Inspection
{
/// <summary>
/// 数据转发规则 DTOId 由 long 改为 string,避免前端精度丢失)
/// </summary>
public class DataForwardRuleDto
{
/// <summary>主键 Idlong → string</summary>
public string Id { get; set; }
/// <summary>删除状态(0、未删除;1、已删除)</summary>
public byte IsDel { get; set; }
/// <summary>创建时间</summary>
public DateTime? CreateTime { get; set; }
/// <summary>转发规则名称</summary>
public string ForwardName { get; set; }
/// <summary>源设备类型(空表示全部设备)</summary>
public string SourceDeviceType { get; set; }
/// <summary>转发间隔(秒)</summary>
public int ForwardInterval { get; set; }
/// <summary>通知方式(Email / Sms / Http</summary>
public string NotifyType { get; set; }
/// <summary>目标地址(邮箱 / 手机号 / 接口URL</summary>
public string TargetAddress { get; set; }
/// <summary>是否启用</summary>
public bool Enabled { get; set; }
/// <summary>最近转发时间</summary>
public DateTime? LastForwardTime { get; set; }
/// <summary>备注</summary>
public string Remark { get; set; }
}
}
+212
View File
@@ -0,0 +1,212 @@
namespace Model.Dto.Inspection
{
#region
/// <summary>
/// 设备统计概览
/// </summary>
public class DeviceStatisticsDto
{
/// <summary>设备总数</summary>
public int Total { get; set; }
/// <summary>在用数量</summary>
public int InUse { get; set; }
/// <summary>闲置数量</summary>
public int Idle { get; set; }
/// <summary>报废数量(已处置+待处置)</summary>
public int Scrapped { get; set; }
/// <summary>维修中数量</summary>
public int Repairing { get; set; }
/// <summary>已停用数量</summary>
public int Suspended { get; set; }
/// <summary>其他数量(待验收/已借出/入库)</summary>
public int Other { get; set; }
}
/// <summary>
/// 按分类统计
/// </summary>
public class CategoryStatisticsDto
{
/// <summary>分类名称</summary>
public string? CategoryName { get; set; }
/// <summary>设备数量</summary>
public int Count { get; set; }
}
/// <summary>
/// 按部门统计
/// </summary>
public class DepartmentStatisticsDto
{
/// <summary>部门名称</summary>
public string? Department { get; set; }
/// <summary>设备数量</summary>
public int Count { get; set; }
}
#endregion
#region
/// <summary>
/// 设备维护统计
/// </summary>
public class MaintenanceStatisticsDto
{
/// <summary>设备总数</summary>
public int Total { get; set; }
/// <summary>校准正常数量</summary>
public int CalibrationNormal { get; set; }
/// <summary>校准超期数量</summary>
public int CalibrationOverdue { get; set; }
/// <summary>保养正常数量</summary>
public int MaintenanceNormal { get; set; }
/// <summary>保养超期数量</summary>
public int MaintenanceOverdue { get; set; }
/// <summary>校准及时率(%</summary>
public decimal CalibrationTimelyRate { get; set; }
/// <summary>保养及时率(%</summary>
public decimal MaintenanceTimelyRate { get; set; }
}
/// <summary>
/// 维护超期设备明细
/// </summary>
public class MaintenanceOverdueDetailDto
{
/// <summary>设备编号</summary>
public string? EquipmentCode { get; set; }
/// <summary>设备名称</summary>
public string? EquipmentName { get; set; }
/// <summary>部门</summary>
public string? Department { get; set; }
/// <summary>超期类型(校准超期/保养超期)</summary>
public string? OverdueType { get; set; }
/// <summary>到期日期</summary>
public DateTime? ExpireDate { get; set; }
/// <summary>超期天数</summary>
public int OverdueDays { get; set; }
}
#endregion
#region
/// <summary>
/// 维修统计概览
/// </summary>
public class RepairStatisticsDto
{
/// <summary>维修记录总数</summary>
public int Total { get; set; }
/// <summary>已完成数量</summary>
public int Completed { get; set; }
/// <summary>维修中数量</summary>
public int InProgress { get; set; }
/// <summary>待维修数量</summary>
public int Pending { get; set; }
/// <summary>平均维修工时(小时)</summary>
public decimal AvgRepairHours { get; set; }
/// <summary>总维修工时(小时)</summary>
public decimal TotalRepairHours { get; set; }
/// <summary>总维修费用(元)</summary>
public decimal TotalRepairCost { get; set; }
}
/// <summary>
/// 故障类型分布
/// </summary>
public class FaultTypeDistributionDto
{
/// <summary>故障类型</summary>
public string? FaultType { get; set; }
/// <summary>数量</summary>
public int Count { get; set; }
}
/// <summary>
/// 维修工时分析
/// </summary>
public class RepairHoursAnalysisDto
{
/// <summary>故障类型</summary>
public string? FaultType { get; set; }
/// <summary>平均工时(小时)</summary>
public decimal AvgHours { get; set; }
/// <summary>最大工时(小时)</summary>
public decimal MaxHours { get; set; }
/// <summary>最小工时(小时)</summary>
public decimal MinHours { get; set; }
/// <summary>记录数</summary>
public int Count { get; set; }
}
/// <summary>
/// 维修记录明细
/// </summary>
public class RepairRecordDetailDto
{
/// <summary>设备编号</summary>
public string? EquipmentCode { get; set; }
/// <summary>设备名称</summary>
public string? EquipmentName { get; set; }
/// <summary>故障类型</summary>
public string? FaultType { get; set; }
/// <summary>故障描述</summary>
public string? FaultDescription { get; set; }
/// <summary>报修日期</summary>
public DateTime? ReportDate { get; set; }
/// <summary>维修完成日期</summary>
public DateTime? RepairEndDate { get; set; }
/// <summary>维修工时</summary>
public decimal? RepairHours { get; set; }
/// <summary>维修人员</summary>
public string? RepairPerson { get; set; }
/// <summary>维修费用</summary>
public decimal? RepairCost { get; set; }
/// <summary>维修状态</summary>
public string? RepairStatus { get; set; }
}
#endregion
}
+44
View File
@@ -0,0 +1,44 @@
namespace Model.Dto.System
{
/// <summary>
/// 数据字典分类 DTO
/// </summary>
public class DictTypeDto
{
public string Id { get; set; }
public string Code { get; set; }
public string Name { get; set; }
public byte Status { get; set; }
public int Sort { get; set; }
public string? Remark { get; set; }
public DateTime? CreateTime { get; set; }
/// <summary>该分类下的字典项数量(列表展示用,由 Service 填充)</summary>
public int ItemCount { get; set; }
}
/// <summary>
/// 数据字典项 DTO
/// </summary>
public class DictItemDto
{
public string Id { get; set; }
public string TypeId { get; set; }
public string Code { get; set; }
public string Name { get; set; }
public int Sort { get; set; }
public byte Status { get; set; }
public byte IsDefault { get; set; }
public string? Remark { get; set; }
public DateTime? CreateTime { get; set; }
}
/// <summary>
/// 字典项下拉选项(业务侧通过 typeCode 查询用:仅返回编码+文本+是否默认)
/// </summary>
public class DictOptionDto
{
public string Code { get; set; }
public string Name { get; set; }
public bool IsDefault { get; set; }
}
}
+60
View File
@@ -0,0 +1,60 @@
namespace Model.Dto.System
{
/// <summary>
/// 文件存储配置 DTO
/// </summary>
public class FileStorageConfigDto
{
public string Id { get; set; }
public string Provider { get; set; }
public string Name { get; set; }
public string? Endpoint { get; set; }
/// <summary>回显时掩码(仅显示前后各 2 字符);写入时若为掩码样式则保留原值不变</summary>
public string? AccessKey { get; set; }
public string? SecretKey { get; set; }
public string? Bucket { get; set; }
public string? Region { get; set; }
public string? BasePath { get; set; }
public int MaxSizeMB { get; set; }
public string? AllowedExts { get; set; }
public byte IsDefault { get; set; }
public byte Status { get; set; }
public string? Remark { get; set; }
public DateTime? CreateTime { get; set; }
}
/// <summary>
/// 文件上传记录 DTO
/// </summary>
public class FileRecordDto
{
public string Id { get; set; }
public string FileName { get; set; }
public string? OriginalName { get; set; }
public string? Url { get; set; }
public long Size { get; set; }
public string? Ext { get; set; }
public string? Provider { get; set; }
public string StorageId { get; set; }
public string? Uploader { get; set; }
public string? BizType { get; set; }
public string? BizId { get; set; }
public DateTime? CreateTime { get; set; }
}
/// <summary>
/// 上传结果(Provider 返回,Service 写入 sys_file_record 时用)
/// </summary>
public class FileUploadResultDto
{
/// <summary>存储后的相对文件名(含路径,如 2026/09/18/snowflake.jpg</summary>
public string FileName { get; set; }
/// <summary>访问 URL</summary>
public string Url { get; set; }
/// <summary>扩展名</summary>
public string? Ext { get; set; }
/// <summary>文件大小(字节)</summary>
public long Size { get; set; }
public string Provider { get; set; }
}
}
+56
View File
@@ -0,0 +1,56 @@
namespace Model.Dto.System
{
/// <summary>
/// 组织架构树节点 DTO(嵌套子级;含数据权限摘要)
/// </summary>
public class OrgTreeDto
{
public string Id { get; set; }
public string ParentId { get; set; }
public string Name { get; set; }
public string Code { get; set; }
public int OrgType { get; set; }
public int ShiftType { get; set; }
public string? Leader { get; set; }
public string? Phone { get; set; }
public int Sort { get; set; }
public string? Remark { get; set; }
public List<OrgTreeDto> Children { get; set; } = new();
/// <summary>直接挂靠用户数</summary>
public int UserCount { get; set; }
/// <summary>本组织及下级用户总数</summary>
public int TotalUserCount { get; set; }
/// <summary>数据权限摘要:本组织及下级用户的角色+数据范围(去重,如「实验室管理员·本组织及下级」)</summary>
public List<string> ScopeSummary { get; set; } = new();
}
/// <summary>
/// 组织架构编辑 DTO(新增/修改用)
/// </summary>
public class OrgDto
{
public string Id { get; set; }
public string ParentId { get; set; }
public string Name { get; set; }
public string Code { get; set; }
public int OrgType { get; set; }
public int ShiftType { get; set; }
public string? Leader { get; set; }
public string? Phone { get; set; }
public int Sort { get; set; }
public string? Remark { get; set; }
}
/// <summary>
/// 组织下拉选项 DTO(用户表单选组织用)
/// </summary>
public class OrgOptionDto
{
public string Id { get; set; }
public string Name { get; set; }
public int OrgType { get; set; }
}
}
+37
View File
@@ -0,0 +1,37 @@
namespace Model.Dto.System
{
/// <summary>
/// 权限 DTO
/// </summary>
public class PermissionDto
{
public string Id { get; set; }
public string Code { get; set; }
public string Name { get; set; }
public string? Group { get; set; }
public byte IsSystem { get; set; }
public int Sort { get; set; }
}
/// <summary>
/// 审计日志 DTO
/// </summary>
public class AuditLogDto
{
public string Id { get; set; }
public string UserId { get; set; }
public string? UserName { get; set; }
public string RoleId { get; set; }
public string? RoleName { get; set; }
public string OperationType { get; set; }
public string OperationTarget { get; set; }
public string TargetId { get; set; }
public string DeviceId { get; set; }
public string? OldValue { get; set; }
public string? NewValue { get; set; }
public string? Ip { get; set; }
public string? Description { get; set; }
public DateTime OperateTime { get; set; }
public DateTime? CreateTime { get; set; }
}
}
+31
View File
@@ -0,0 +1,31 @@
namespace Model.Dto.System
{
/// <summary>
/// 角色 DTO
/// </summary>
public class RoleDto
{
public string Id { get; set; }
public string Code { get; set; }
public string Name { get; set; }
public byte DataScope { get; set; }
public byte IsSystem { get; set; }
public int Sort { get; set; }
public string? Remark { get; set; }
public DateTime? CreateTime { get; set; }
/// <summary>角色的权限Id列表(分配权限用)</summary>
public List<string>? PermissionIds { get; set; }
/// <summary>角色的权限编码列表(展示用)</summary>
public List<string>? PermissionCodes { get; set; }
}
/// <summary>
/// 角色下拉选项 DTO
/// </summary>
public class RoleOptionDto
{
public string Id { get; set; }
public string Code { get; set; }
public string Name { get; set; }
}
}
+35
View File
@@ -0,0 +1,35 @@
namespace Model.Dto.System
{
/// <summary>
/// 系统参数 DTO
/// </summary>
public class SystemParamDto
{
public string Id { get; set; }
public string ParamKey { get; set; }
public string ParamName { get; set; }
public string? ParamValue { get; set; }
/// <summary>值类型(0=int, 1=text, 2=enum, 3=json</summary>
public byte ParamType { get; set; }
/// <summary>枚举选项(原始 JSON 字符串,由前端解析;仅 ParamType=enum 有值)</summary>
public string? ParamOptions { get; set; }
public string? Unit { get; set; }
public string? Group { get; set; }
public int Sort { get; set; }
public string? Remark { get; set; }
/// <summary>是否系统内置(1=不可删,仅允许改 Value</summary>
public byte IsSystem { get; set; }
public string? LastUpdateUser { get; set; }
public DateTime? LastUpdateTime { get; set; }
public DateTime? CreateTime { get; set; }
}
/// <summary>
/// 业务侧批量取值返回项
/// </summary>
public class SystemParamValueDto
{
public string Key { get; set; }
public string? Value { get; set; }
}
}
+68
View File
@@ -0,0 +1,68 @@
namespace Model.Dto.System
{
/// <summary>
/// 用户 DTO
/// </summary>
public class UserDto
{
public string Id { get; set; }
public string UserName { get; set; }
public string? RealName { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string OrgId { get; set; }
public string? OrgName { get; set; }
public string? Position { get; set; }
public string? SkillTags { get; set; }
public byte IsEnabled { get; set; }
public DateTime? LastLoginTime { get; set; }
public string? Avatar { get; set; }
public string? Remark { get; set; }
public DateTime? CreateTime { get; set; }
/// <summary>用户的角色Id列表(分配角色用)</summary>
public List<string>? RoleIds { get; set; }
/// <summary>用户的角色名称(展示用,逗号分隔)</summary>
public string? RoleNames { get; set; }
/// <summary>初始密码(仅新增用户时传入,不回显)</summary>
public string? InitialPassword { get; set; }
}
/// <summary>
/// 登录请求 DTO
/// </summary>
public class LoginDto
{
public string UserName { get; set; }
public string Password { get; set; }
}
/// <summary>
/// 登录结果 DTO
/// </summary>
public class LoginResultDto
{
public string Token { get; set; }
public string RefreshToken { get; set; }
public UserDto User { get; set; }
public List<string> Permissions { get; set; } = new();
}
/// <summary>
/// 修改密码 DTO
/// </summary>
public class ChangePasswordDto
{
public string OldPassword { get; set; }
public string NewPassword { get; set; }
}
/// <summary>
/// 用户下拉选项 DTO
/// </summary>
public class UserOptionDto
{
public string Id { get; set; }
public string UserName { get; set; }
public string? RealName { get; set; }
}
}
+14 -1
View File
@@ -1,4 +1,4 @@
using SqlSugar;
using SqlSugar;
namespace Model.Entity.Asset
{
@@ -73,6 +73,19 @@ namespace Model.Entity.Asset
[SugarColumn(Length = 50, IsNullable = true)]
public string? QrCode { get; set; }
/// <summary>
/// 二维码扫码直达 URL(生成二维码时写入,扫码后跳转设备台账详情)
/// 形如 http://host/asset/ledger/equipment?id={Id};为空表示尚未生成二维码
/// </summary>
[SugarColumn(Length = 500, IsNullable = true)]
public string? QrCodeUrl { get; set; }
/// <summary>
/// RFID 编号(绑定时写入,用于 RFID 标签识别设备)
/// </summary>
[SugarColumn(Length = 64, IsNullable = true)]
public string? RfidCode { get; set; }
/// <summary>
/// 设备描述
/// </summary>
+47
View File
@@ -0,0 +1,47 @@
using SqlSugar;
namespace Model.Entity.Config
{
/// <summary>
/// 设备日志(每台设备独享的日志记录)
/// 记录设备的通讯、连接、数据采集等运行日志,按设备查询
/// </summary>
public class DeviceLogEntity : BaseEntity
{
/// <summary>
/// 设备Id(关联 IotDeviceEntity.Id
/// </summary>
[SugarColumn(ColumnDescription = "设备Id")]
public long DeviceId { get; set; }
/// <summary>
/// 设备编号(冗余便于查询展示)
/// </summary>
[SugarColumn(ColumnDescription = "设备编号", Length = 64)]
public string? DeviceCode { get; set; }
/// <summary>
/// 日志级别(Info/Warn/Error
/// </summary>
[SugarColumn(ColumnDescription = "日志级别", Length = 16)]
public string? Level { get; set; } = "Info";
/// <summary>
/// 日志类型(连接/通讯/采集/指令/系统)
/// </summary>
[SugarColumn(ColumnDescription = "日志类型", Length = 32)]
public string? LogType { get; set; }
/// <summary>
/// 日志内容
/// </summary>
[SugarColumn(ColumnDescription = "日志内容", ColumnDataType = "text")]
public string? Message { get; set; }
/// <summary>
/// 记录时间(日志产生时间,用于时间线展示)
/// </summary>
[SugarColumn(ColumnDescription = "记录时间")]
public DateTime LogTime { get; set; }
}
}
+127
View File
@@ -0,0 +1,127 @@
using SqlSugar;
namespace Model.Entity.Config
{
/// <summary>
/// 网关(通讯通道):TCP 类存 IP+端口,串口类存 COM+波特率,S7 类额外存 CPU/Rack/Slot。
/// 同一网关下多设备用从站号(SlaveId)区分。
/// </summary>
public class GatewayEntity : BaseEntity
{
/// <summary>
/// 网关编码(唯一,如 GW-ENV-01)
/// </summary>
[SugarColumn(ColumnDescription = "网关编码", Length = 64)]
public string Code { get; set; }
/// <summary>
/// 网关名称
/// </summary>
[SugarColumn(ColumnDescription = "网关名称", Length = 100)]
public string Name { get; set; }
/// <summary>
/// 通讯协议类型(决定连接参数组:TCP/Serial/S7
/// </summary>
[SugarColumn(ColumnDescription = "通讯协议类型")]
public IotDeviceProtocolEnum ProtocolType { get; set; } = IotDeviceProtocolEnum.ModbusTcp;
#region TCP ModbusTcp / Tcp / S7 使
/// <summary>
/// IP 地址
/// </summary>
[SugarColumn(ColumnDescription = "IP地址", Length = 50, IsNullable = true)]
public string? Host { get; set; }
/// <summary>
/// 端口号(ModbusTcp 默认 502S7 默认 102
/// </summary>
[SugarColumn(ColumnDescription = "端口号", IsNullable = true)]
public int? Port { get; set; }
#endregion
#region ModbusRtu / Serial 使
/// <summary>
/// 串口号(如 COM3
/// </summary>
[SugarColumn(ColumnDescription = "串口号", Length = 20, IsNullable = true)]
public string? ComPort { get; set; }
/// <summary>
/// 波特率(默认 9600
/// </summary>
[SugarColumn(ColumnDescription = "波特率", IsNullable = true)]
public int? BaudRate { get; set; }
/// <summary>
/// 数据位(默认 8
/// </summary>
[SugarColumn(ColumnDescription = "数据位", IsNullable = true)]
public byte? DataBits { get; set; }
/// <summary>
/// 停止位(1/2,默认 1
/// </summary>
[SugarColumn(ColumnDescription = "停止位", IsNullable = true)]
public byte? StopBits { get; set; }
/// <summary>
/// 校验位(0=None 1=Odd 2=Even,默认 0
/// </summary>
[SugarColumn(ColumnDescription = "校验位", IsNullable = true)]
public byte? Parity { get; set; }
#endregion
#region S7 S7
/// <summary>
/// S7 CPU 型号(如 S71200、S71500
/// </summary>
[SugarColumn(ColumnDescription = "S7 CPU型号", Length = 20, IsNullable = true)]
public string? CpuType { get; set; }
/// <summary>
/// S7 Rack(默认 0
/// </summary>
[SugarColumn(ColumnDescription = "S7 Rack", IsNullable = true)]
public byte? Rack { get; set; }
/// <summary>
/// S7 Slot(默认 1
/// </summary>
[SugarColumn(ColumnDescription = "S7 Slot", IsNullable = true)]
public byte? Slot { get; set; }
#endregion
#region
/// <summary>
/// 是否启用
/// </summary>
[SugarColumn(ColumnDescription = "是否启用")]
public bool IsEnabled { get; set; } = true;
/// <summary>
/// 在线状态(0=离线 1=在线 3=异常,由测试连接更新)
/// </summary>
[SugarColumn(ColumnDescription = "在线状态")]
public byte OnlineStatus { get; set; }
/// <summary>
/// 最后连接成功时间
/// </summary>
[SugarColumn(ColumnDescription = "最后连接时间", IsNullable = true)]
public DateTime? LastConnectedTime { get; set; }
/// <summary>
/// 最近错误
/// </summary>
[SugarColumn(ColumnDescription = "最近错误", Length = 500, IsNullable = true)]
public string? LastError { get; set; }
#endregion
/// <summary>
/// 备注
/// </summary>
[SugarColumn(ColumnDescription = "备注", Length = 500, IsNullable = true)]
public string? Remark { get; set; }
}
}
+103
View File
@@ -0,0 +1,103 @@
using SqlSugar;
namespace Model.Entity.Config
{
/// <summary>
/// IOT 设备(IOT设备注册与管理 - 设备管理)
/// 一台设备 = 一个从站实例,通过所属网关(IP:端口 或 串口)通讯
/// </summary>
public class IotDeviceEntity : BaseEntity
{
#region
/// <summary>
/// 设备编号(唯一)
/// </summary>
[SugarColumn(ColumnDescription = "设备编号", Length = 64)]
public string Code { get; set; }
/// <summary>
/// 设备名称
/// </summary>
[SugarColumn(ColumnDescription = "设备名称", Length = 100)]
public string Name { get; set; }
/// <summary>
/// 设备类型(型号,如 温度箱/充放电柜,对应 DeviceCommand 驱动类)
/// </summary>
[SugarColumn(ColumnDescription = "设备类型", Length = 64)]
public string DeviceType { get; set; }
/// <summary>
/// 所属产品Id(关联 ProductEntity.Id,仅用于归类,0=未分类)
/// </summary>
[SugarColumn(ColumnDescription = "所属产品Id", DefaultValue = "0")]
public long ProductId { get; set; }
/// <summary>
/// 所属网关Id(关联 GatewayEntity.Id0=未分配)
/// </summary>
[SugarColumn(ColumnDescription = "所属网关Id", DefaultValue = "0")]
public long GatewayId { get; set; }
/// <summary>
/// 从站地址(协议从站号,网关下区分设备)
/// </summary>
[SugarColumn(ColumnDescription = "从站地址")]
public byte SlaveId { get; set; }
/// <summary>
/// 制造商
/// </summary>
[SugarColumn(ColumnDescription = "制造商", Length = 100, IsNullable = true)]
public string? Manufacturer { get; set; }
/// <summary>
/// 设备描述
/// </summary>
[SugarColumn(ColumnDescription = "设备描述", Length = 500, IsNullable = true)]
public string? Description { get; set; }
#endregion
#region
/// <summary>
/// 通讯协议类型(ModbusTcp/ModbusRtu/S7/Tcp/Serial
/// </summary>
[SugarColumn(ColumnDescription = "通讯协议类型")]
public IotDeviceProtocolEnum ProtocolType { get; set; } = IotDeviceProtocolEnum.ModbusTcp;
/// <summary>
/// 是否启用采集(停用则采集调度跳过该设备)
/// </summary>
[SugarColumn(ColumnDescription = "是否启用")]
public bool IsEnabled { get; set; } = true;
#endregion
#region
/// <summary>
/// 在线状态
/// </summary>
[SugarColumn(ColumnDescription = "在线状态")]
public IotDeviceOnlineStatusEnum OnlineStatus { get; set; } = IotDeviceOnlineStatusEnum.Offline;
/// <summary>
/// 最后采集时间
/// </summary>
[SugarColumn(ColumnDescription = "最后采集时间", IsNullable = true)]
public DateTime? LastCollectTime { get; set; }
/// <summary>
/// 最近错误信息(通讯异常时记录)
/// </summary>
[SugarColumn(ColumnDescription = "最近错误", Length = 500, IsNullable = true)]
public string? LastError { get; set; }
#endregion
#region
/// <summary>
/// 备注
/// </summary>
[SugarColumn(ColumnDescription = "备注", Length = 500, IsNullable = true)]
public string? Remark { get; set; }
#endregion
}
}
+59
View File
@@ -0,0 +1,59 @@
namespace Model.Entity.Config
{
/// <summary>
/// 设备通讯协议类型(决定使用 DeviceCommand 中哪类驱动)
/// </summary>
public enum IotDeviceProtocolEnum
{
/// <summary>
/// Modbus TCP(网口,IP + 端口 + 从站地址)
/// </summary>
ModbusTcp = 1,
/// <summary>
/// Modbus RTU(串口,COM 口 + 波特率 + 从站地址)
/// </summary>
ModbusRtu = 2,
/// <summary>
/// 西门子 S7 协议
/// </summary>
S7 = 3,
/// <summary>
/// 自定义 TCP 协议
/// </summary>
Tcp = 4,
/// <summary>
/// 串口自定义协议
/// </summary>
Serial = 5
}
/// <summary>
/// 设备在线状态
/// </summary>
public enum IotDeviceOnlineStatusEnum
{
/// <summary>
/// 离线
/// </summary>
Offline = 0,
/// <summary>
/// 在线
/// </summary>
Online = 1,
/// <summary>
/// 连接中(网关已连,设备待确认)
/// </summary>
Connecting = 2,
/// <summary>
/// 通讯异常
/// </summary>
Fault = 3
}
}
@@ -0,0 +1,34 @@
using SqlSugar;
namespace Model.Entity.Config
{
/// <summary>
/// 产品分类(字典:维护产品分类,新增产品时可设置分类)
/// </summary>
public class ProductCategoryEntity : BaseEntity
{
/// <summary>
/// 分类编码
/// </summary>
[SugarColumn(ColumnDescription = "分类编码", Length = 50)]
public string Code { get; set; }
/// <summary>
/// 分类名称
/// </summary>
[SugarColumn(ColumnDescription = "分类名称", Length = 100, IsNullable = true)]
public string? Name { get; set; }
/// <summary>
/// 排序号
/// </summary>
[SugarColumn(ColumnDescription = "排序号")]
public int Sort { get; set; }
/// <summary>
/// 备注
/// </summary>
[SugarColumn(ColumnDescription = "备注", Length = 500, IsNullable = true)]
public string? Remark { get; set; }
}
}
+64
View File
@@ -0,0 +1,64 @@
using SqlSugar;
namespace Model.Entity.Config
{
/// <summary>
/// 产品(按型号区分;产品给设备归类,产品的物模型/告警等配置与设备相互独立)
/// </summary>
public class ProductEntity : BaseEntity
{
/// <summary>
/// 型号(唯一,如 GT-100
/// </summary>
[SugarColumn(ColumnDescription = "型号", Length = 100)]
public string Model { get; set; }
/// <summary>
/// 产品名称
/// </summary>
[SugarColumn(ColumnDescription = "产品名称", Length = 100, IsNullable = true)]
public string? Name { get; set; }
/// <summary>
/// 产品分类Id(关联 ProductCategoryEntity.Id0 表示未分类)
/// </summary>
[SugarColumn(ColumnDescription = "产品分类Id")]
public long CategoryId { get; set; }
/// <summary>
/// 制造商
/// </summary>
[SugarColumn(ColumnDescription = "制造商", Length = 100, IsNullable = true)]
public string? Manufacturer { get; set; }
/// <summary>
/// 产品描述
/// </summary>
[SugarColumn(ColumnDescription = "产品描述", Length = 500, IsNullable = true)]
public string? Description { get; set; }
/// <summary>
/// 通讯协议(复用 IotDeviceProtocolEnum:决定 DeviceCommand 驱动分支)
/// </summary>
[SugarColumn(ColumnDescription = "通讯协议")]
public IotDeviceProtocolEnum ProtocolType { get; set; } = IotDeviceProtocolEnum.ModbusTcp;
/// <summary>
/// 消息模型(消息协议/报文格式描述,如 JSON / 透传)
/// </summary>
[SugarColumn(ColumnDescription = "消息模型", Length = 64, IsNullable = true)]
public string? MessageModel { get; set; }
/// <summary>
/// 是否启用
/// </summary>
[SugarColumn(ColumnDescription = "是否启用")]
public bool IsEnabled { get; set; } = true;
/// <summary>
/// 备注
/// </summary>
[SugarColumn(ColumnDescription = "备注", Length = 500, IsNullable = true)]
public string? Remark { get; set; }
}
}
+91
View File
@@ -0,0 +1,91 @@
namespace Model.Entity.Config
{
/// <summary>
/// 物模型归属类型(物模型点表 OwnerType:点挂在哪一类对象下)
/// </summary>
public enum ThingOwnerTypeEnum
{
/// <summary>
/// 产品(型号)
/// </summary>
Product = 1,
/// <summary>
/// 设备(实例)
/// </summary>
Device = 2
}
/// <summary>
/// 点读写权限(决定该点是否可用于下发指令)
/// </summary>
public enum ThingRwEnum
{
/// <summary>
/// 只读
/// </summary>
ReadOnly = 1,
/// <summary>
/// 只写
/// </summary>
WriteOnly = 2,
/// <summary>
/// 读写
/// </summary>
ReadWrite = 3
}
/// <summary>
/// 寄存器类型
/// </summary>
public enum ThingRegisterTypeEnum
{
/// <summary>
/// 线圈(读/写 bool
/// </summary>
Coil = 1,
/// <summary>
/// 保持寄存器(读/写)
/// </summary>
HoldingRegister = 2,
/// <summary>
/// 输入寄存器(只读,如 UMC1300 的温度 PV)
/// </summary>
InputRegister = 3
}
/// <summary>
/// 数据类型
/// </summary>
public enum ThingDataTypeEnum
{
/// <summary>
/// 布尔
/// </summary>
Bool = 1,
/// <summary>
/// 16位有符号整数
/// </summary>
Int16 = 2,
/// <summary>
/// 16位无符号整数
/// </summary>
UInt16 = 3,
/// <summary>
/// 32位整数(占2寄存器,暂不支持下发)
/// </summary>
Int32 = 4,
/// <summary>
/// 32位浮点(占2寄存器,暂不支持下发)
/// </summary>
Float = 5
}
}
@@ -0,0 +1,95 @@
using SqlSugar;
namespace Model.Entity.Config
{
/// <summary>
/// 物模型点(属性/指令通用表;通过 OwnerType+OwnerId 归属产品或设备)
/// 一条点 = 一个寄存器/线圈映射;可写点即"下发指令"的入口
/// </summary>
public class ThingModelPointEntity : BaseEntity
{
/// <summary>
/// 归属类型(1=产品 2=设备)
/// </summary>
[SugarColumn(ColumnDescription = "归属类型")]
public ThingOwnerTypeEnum OwnerType { get; set; }
/// <summary>
/// 归属对象IdProductEntity.Id / IotDeviceEntity.Id
/// </summary>
[SugarColumn(ColumnDescription = "归属对象Id")]
public long OwnerId { get; set; }
/// <summary>
/// 点编码(同一归属内唯一,如 TempPV)
/// </summary>
[SugarColumn(ColumnDescription = "点编码", Length = 64)]
public string Code { get; set; }
/// <summary>
/// 点名称
/// </summary>
[SugarColumn(ColumnDescription = "点名称", Length = 100, IsNullable = true)]
public string? Name { get; set; }
/// <summary>
/// 寄存器类型(线圈/保持寄存器/输入寄存器)
/// </summary>
[SugarColumn(ColumnDescription = "寄存器类型")]
public ThingRegisterTypeEnum RegisterType { get; set; }
/// <summary>
/// 读写权限
/// </summary>
[SugarColumn(ColumnDescription = "读写权限")]
public ThingRwEnum Rw { get; set; }
/// <summary>
/// 数据类型
/// </summary>
[SugarColumn(ColumnDescription = "数据类型")]
public ThingDataTypeEnum DataType { get; set; }
/// <summary>
/// 寄存器起始地址
/// </summary>
[SugarColumn(ColumnDescription = "寄存器起始地址")]
public ushort Address { get; set; }
/// <summary>
/// 换算系数(工程值 = 寄存器原始值 / Scale + Offset,对应驱动内 SCALE
/// </summary>
[SugarColumn(ColumnDescription = "换算系数", DecimalDigits = 4)]
public double Scale { get; set; } = 1;
/// <summary>
/// 偏移量
/// </summary>
[SugarColumn(ColumnDescription = "偏移量", DecimalDigits = 4)]
public double Offset { get; set; }
/// <summary>
/// 单位
/// </summary>
[SugarColumn(ColumnDescription = "单位", Length = 16, IsNullable = true)]
public string? Unit { get; set; }
/// <summary>
/// 是否启用
/// </summary>
[SugarColumn(ColumnDescription = "是否启用")]
public bool Enabled { get; set; } = true;
/// <summary>
/// 排序号
/// </summary>
[SugarColumn(ColumnDescription = "排序号")]
public int Sort { get; set; }
/// <summary>
/// 备注
/// </summary>
[SugarColumn(ColumnDescription = "备注", Length = 500, IsNullable = true)]
public string? Remark { get; set; }
}
}
+221
View File
@@ -0,0 +1,221 @@
namespace Model.Entity.Inspection
{
/// <summary>
/// 告警级别(与规则引擎 AlertRuleEntity.AlarmLevel 字符串"信息/警告/紧急"保持一致)
/// </summary>
public enum AlertLevelEnum
{
/// <summary>
/// 信息
/// </summary>
Info = 1,
/// <summary>
/// 警告
/// </summary>
Warning = 2,
/// <summary>
/// 紧急
/// </summary>
Urgent = 3
}
/// <summary>
/// 告警状态(闭环流转:未确认 → 已确认 → 已处理 → 已关闭;可忽略/转工单)
/// </summary>
public enum AlertStatusEnum
{
/// <summary>
/// 未确认
/// </summary>
Unacknowledged = 1,
/// <summary>
/// 已确认
/// </summary>
Acknowledged = 2,
/// <summary>
/// 已处理
/// </summary>
Handled = 3,
/// <summary>
/// 已关闭
/// </summary>
Closed = 4,
/// <summary>
/// 已忽略
/// </summary>
Ignored = 5,
/// <summary>
/// 已转工单
/// </summary>
ToWorkOrder = 6
}
/// <summary>
/// 告警来源(标识上报模块,便于其它模块调用与统计)
/// </summary>
public enum AlertSourceEnum
{
/// <summary>
/// 规则引擎
/// </summary>
RuleEngine = 1,
/// <summary>
/// 自动巡检
/// </summary>
AutoPatrol = 2,
/// <summary>
/// 数据看板
/// </summary>
Dashboard = 3,
/// <summary>
/// 数据采集
/// </summary>
Collection = 4,
/// <summary>
/// 手动/API 上报
/// </summary>
Manual = 5,
/// <summary>
/// 其它
/// </summary>
Other = 99
}
/// <summary>
/// 告警类型
/// </summary>
public enum AlertTypeEnum
{
/// <summary>
/// 阈值超限
/// </summary>
ThresholdExceed = 1,
/// <summary>
/// 设备离线
/// </summary>
DeviceOffline = 2,
/// <summary>
/// 通讯故障
/// </summary>
CommFault = 3,
/// <summary>
/// 巡检异常
/// </summary>
PatrolAbnormal = 4,
/// <summary>
/// 自定义
/// </summary>
Custom = 99
}
/// <summary>
/// 通知渠道类型(一期仅支持飞书/钉钉/企微 Webhook 机器人)
/// </summary>
public enum NotifyChannelTypeEnum
{
/// <summary>
/// 飞书
/// </summary>
Feishu = 1,
/// <summary>
/// 钉钉
/// </summary>
DingTalk = 2,
/// <summary>
/// 企业微信
/// </summary>
WeCom = 3
}
/// <summary>
/// 渠道推送模式
/// </summary>
public enum NotifyPushModeEnum
{
/// <summary>
/// 直连推送(服务器可直接访问外网 Webhook)
/// </summary>
Direct = 1,
/// <summary>
/// Outbox 中转(跨网场景:内网写发件箱,跳板机 AlertBridge 拉取后在外网推送)
/// </summary>
Outbox = 2
}
/// <summary>
/// 跨网发件箱状态
/// </summary>
public enum OutboxStatusEnum
{
/// <summary>
/// 待推送
/// </summary>
Pending = 1,
/// <summary>
/// 已拉取(跳板机取走,等待回写结果)
/// </summary>
Pulled = 2,
/// <summary>
/// 推送成功
/// </summary>
Success = 3,
/// <summary>
/// 推送失败
/// </summary>
Failed = 4
}
/// <summary>
/// 告警级别枚举 ↔ 规则引擎字符串 映射工具
/// </summary>
public static class AlertLevelMap
{
/// <summary>
/// 字符串(信息/警告/紧急)→ 枚举,无法识别时默认 Info
/// </summary>
public static AlertLevelEnum FromString(string level)
{
return level?.Trim() switch
{
"警告" => AlertLevelEnum.Warning,
"紧急" => AlertLevelEnum.Urgent,
_ => AlertLevelEnum.Info
};
}
/// <summary>
/// 枚举 → 字符串(信息/警告/紧急)
/// </summary>
public static string ToLabel(AlertLevelEnum level)
{
return level switch
{
AlertLevelEnum.Warning => "警告",
AlertLevelEnum.Urgent => "紧急",
_ => "信息"
};
}
}
}
@@ -0,0 +1,151 @@
using SqlSugar;
using System;
namespace Model.Entity.Inspection
{
/// <summary>
/// 告警消息(消息告警模块核心表:所有模块的告警统一汇聚于此,支持确认/处理/关闭闭环)
/// </summary>
public class AlertMessageEntity : BaseEntity
{
#region
/// <summary>
/// 告警来源(规则引擎/自动巡检/看板/数采/手动等)
/// </summary>
[SugarColumn(ColumnDescription = "告警来源")]
public AlertSourceEnum Source { get; set; } = AlertSourceEnum.Manual;
/// <summary>
/// 关联告警规则Id(来源为规则引擎时填写,0表示无)
/// </summary>
[SugarColumn(ColumnDescription = "关联告警规则Id")]
public long RuleId { get; set; }
/// <summary>
/// 设备编号
/// </summary>
[SugarColumn(ColumnDescription = "设备编号", Length = 64, IsNullable = true)]
public string DeviceCode { get; set; }
/// <summary>
/// 设备名称
/// </summary>
[SugarColumn(ColumnDescription = "设备名称", Length = 128, IsNullable = true)]
public string DeviceName { get; set; }
/// <summary>
/// 设备类型(如 TemperatureBox,与规则引擎 DeviceType 对齐)
/// </summary>
[SugarColumn(ColumnDescription = "设备类型", Length = 64, IsNullable = true)]
public string DeviceType { get; set; }
/// <summary>
/// 测点/监测指标(如 Temperature、Humidity
/// </summary>
[SugarColumn(ColumnDescription = "测点指标", Length = 64, IsNullable = true)]
public string Metric { get; set; }
/// <summary>
/// 告警类型(超限/离线/通讯故障/巡检异常/自定义)
/// </summary>
[SugarColumn(ColumnDescription = "告警类型")]
public AlertTypeEnum AlertType { get; set; } = AlertTypeEnum.ThresholdExceed;
#endregion
#region
/// <summary>
/// 告警级别(信息/警告/紧急,与规则引擎三级一致)
/// </summary>
[SugarColumn(ColumnDescription = "告警级别")]
public AlertLevelEnum AlertLevel { get; set; } = AlertLevelEnum.Info;
/// <summary>
/// 当前值(触发告警时的实际值)
/// </summary>
[SugarColumn(ColumnDescription = "当前值", IsNullable = true)]
public double? CurrentValue { get; set; }
/// <summary>
/// 阈值(规则阈值,可空)
/// </summary>
[SugarColumn(ColumnDescription = "阈值", IsNullable = true)]
public double? ThresholdValue { get; set; }
/// <summary>
/// 告警内容描述
/// </summary>
[SugarColumn(ColumnDescription = "告警内容", Length = 500, IsNullable = true)]
public string Content { get; set; }
/// <summary>
/// 通知模板编码(预留:对接通知模板模块,空表示使用内置模板)
/// </summary>
[SugarColumn(ColumnDescription = "通知模板编码", Length = 64, IsNullable = true)]
public string TemplateCode { get; set; }
#endregion
#region
/// <summary>
/// 触发次数(合并窗口内同设备同测点同类型告警的累计次数)
/// </summary>
[SugarColumn(ColumnDescription = "触发次数")]
public int TriggerCount { get; set; } = 1;
/// <summary>
/// 首次告警时间
/// </summary>
[SugarColumn(ColumnDescription = "首次告警时间")]
public DateTime FirstAlertTime { get; set; }
/// <summary>
/// 最近告警时间
/// </summary>
[SugarColumn(ColumnDescription = "最近告警时间")]
public DateTime LastAlertTime { get; set; }
#endregion
#region
/// <summary>
/// 告警状态(未确认/已确认/已处理/已关闭/已忽略/已转工单)
/// </summary>
[SugarColumn(ColumnDescription = "告警状态")]
public AlertStatusEnum AlertStatus { get; set; } = AlertStatusEnum.Unacknowledged;
/// <summary>
/// 确认人
/// </summary>
[SugarColumn(ColumnDescription = "确认人", Length = 50, IsNullable = true)]
public string AckBy { get; set; }
/// <summary>
/// 确认时间
/// </summary>
[SugarColumn(ColumnDescription = "确认时间", IsNullable = true)]
public DateTime? AckTime { get; set; }
/// <summary>
/// 处理人
/// </summary>
[SugarColumn(ColumnDescription = "处理人", Length = 50, IsNullable = true)]
public string HandleBy { get; set; }
/// <summary>
/// 处理时间
/// </summary>
[SugarColumn(ColumnDescription = "处理时间", IsNullable = true)]
public DateTime? HandleTime { get; set; }
/// <summary>
/// 处理备注
/// </summary>
[SugarColumn(ColumnDescription = "处理备注", Length = 500, IsNullable = true)]
public string HandleRemark { get; set; }
/// <summary>
/// 关联工单Id(预留二期运维工单模块,0表示未转工单)
/// </summary>
[SugarColumn(ColumnDescription = "关联工单Id")]
public long WorkOrderId { get; set; }
#endregion
}
}
@@ -0,0 +1,52 @@
using SqlSugar;
namespace Model.Entity.Inspection
{
/// <summary>
/// 告警通知渠道(飞书/钉钉/企微 Webhook 机器人配置)
/// </summary>
public class AlertNotifyChannelEntity : BaseEntity
{
/// <summary>
/// 渠道类型(1飞书/2钉钉/3企微)
/// </summary>
[SugarColumn(ColumnDescription = "渠道类型")]
public NotifyChannelTypeEnum ChannelType { get; set; }
/// <summary>
/// 渠道名称(如:设备告警-研发飞书群)
/// </summary>
[SugarColumn(ColumnDescription = "渠道名称", Length = 100)]
public string Name { get; set; }
/// <summary>
/// Webhook 地址
/// </summary>
[SugarColumn(ColumnDescription = "Webhook地址", Length = 500)]
public string WebhookUrl { get; set; }
/// <summary>
/// 加签密钥(钉钉/飞书机器人开启加签时填写,企微机器人无此项)
/// </summary>
[SugarColumn(ColumnDescription = "加签密钥", Length = 200, IsNullable = true)]
public string Secret { get; set; }
/// <summary>
/// 推送模式(1直连推送/2Outbox跨网中转)
/// </summary>
[SugarColumn(ColumnDescription = "推送模式")]
public NotifyPushModeEnum PushMode { get; set; } = NotifyPushModeEnum.Direct;
/// <summary>
/// 是否启用
/// </summary>
[SugarColumn(ColumnDescription = "是否启用")]
public bool IsEnabled { get; set; } = true;
/// <summary>
/// 备注
/// </summary>
[SugarColumn(ColumnDescription = "备注", Length = 500, IsNullable = true)]
public string Remark { get; set; }
}
}
@@ -0,0 +1,65 @@
using SqlSugar;
using System;
namespace Model.Entity.Inspection
{
/// <summary>
/// 告警通知推送日志(每次向渠道推送的结果记录,失败可追溯)
/// </summary>
public class AlertNotifyLogEntity : BaseEntity
{
/// <summary>
/// 告警消息Id(关联 AlertMessageEntity.Id0表示渠道测试推送)
/// </summary>
[SugarColumn(ColumnDescription = "告警消息Id")]
public long AlertId { get; set; }
/// <summary>
/// 渠道类型(1飞书/2钉钉/3企微)
/// </summary>
[SugarColumn(ColumnDescription = "渠道类型")]
public NotifyChannelTypeEnum ChannelType { get; set; }
/// <summary>
/// 渠道Id(关联 AlertNotifyChannelEntity.Id
/// </summary>
[SugarColumn(ColumnDescription = "渠道Id")]
public long ChannelId { get; set; }
/// <summary>
/// 推送目标(Webhook 地址脱敏展示)
/// </summary>
[SugarColumn(ColumnDescription = "推送目标", Length = 200, IsNullable = true)]
public string Target { get; set; }
/// <summary>
/// 推送内容摘要
/// </summary>
[SugarColumn(ColumnDescription = "推送内容", Length = 1000, IsNullable = true)]
public string Content { get; set; }
/// <summary>
/// 是否成功
/// </summary>
[SugarColumn(ColumnDescription = "是否成功")]
public bool Success { get; set; }
/// <summary>
/// 错误信息
/// </summary>
[SugarColumn(ColumnDescription = "错误信息", Length = 500, IsNullable = true)]
public string ErrorMsg { get; set; }
/// <summary>
/// 重试次数
/// </summary>
[SugarColumn(ColumnDescription = "重试次数")]
public int RetryCount { get; set; }
/// <summary>
/// 推送时间
/// </summary>
[SugarColumn(ColumnDescription = "推送时间")]
public DateTime NotifyTime { get; set; }
}
}
@@ -0,0 +1,58 @@
using SqlSugar;
namespace Model.Entity.Inspection
{
/// <summary>
/// 告警通知规则(按告警级别配置通知渠道与接收人,支持值班排班关联与静默期)
/// </summary>
public class AlertNotifyRuleEntity : BaseEntity
{
/// <summary>
/// 规则名称
/// </summary>
[SugarColumn(ColumnDescription = "规则名称", Length = 100)]
public string RuleName { get; set; }
/// <summary>
/// 适用告警级别(信息/警告/紧急)
/// </summary>
[SugarColumn(ColumnDescription = "适用告警级别")]
public AlertLevelEnum AlertLevel { get; set; }
/// <summary>
/// 通知渠道Id(关联 AlertNotifyChannelEntity.Id
/// </summary>
[SugarColumn(ColumnDescription = "通知渠道Id")]
public long ChannelId { get; set; }
/// <summary>
/// 接收人(@手机号/企微账号,逗号分隔,可空表示不@)
/// </summary>
[SugarColumn(ColumnDescription = "接收人", Length = 500, IsNullable = true)]
public string Receivers { get; set; }
/// <summary>
/// 是否通知当班值班人(按告警时间查值班排班表)
/// </summary>
[SugarColumn(ColumnDescription = "是否通知值班人")]
public bool NotifyDutyPerson { get; set; }
/// <summary>
/// 静默期(分钟):同一告警合并窗口内重复触发时,静默期内不重复推送,0表示每次必推
/// </summary>
[SugarColumn(ColumnDescription = "静默期分钟")]
public int SilenceMinutes { get; set; } = 5;
/// <summary>
/// 是否启用
/// </summary>
[SugarColumn(ColumnDescription = "是否启用")]
public bool IsEnabled { get; set; } = true;
/// <summary>
/// 备注
/// </summary>
[SugarColumn(ColumnDescription = "备注", Length = 500, IsNullable = true)]
public string Remark { get; set; }
}
}
@@ -0,0 +1,61 @@
using SqlSugar;
using System;
namespace Model.Entity.Inspection
{
/// <summary>
/// 跨网推送发件箱(内网只写此表;跳板机上的 AlertBridge 控制台程序轮询拉取,
/// 在外网推送飞书/钉钉/企微后回写结果,实现工控内网与即时通讯软件的隔离穿透)
/// </summary>
public class AlertOutboxEntity : BaseEntity
{
/// <summary>
/// 告警消息Id(关联 AlertMessageEntity.Id
/// </summary>
[SugarColumn(ColumnDescription = "告警消息Id")]
public long AlertId { get; set; }
/// <summary>
/// 渠道Id(关联 AlertNotifyChannelEntity.Id
/// </summary>
[SugarColumn(ColumnDescription = "渠道Id")]
public long ChannelId { get; set; }
/// <summary>
/// 完整推送报文(自包含 JSON:渠道类型/WebhookUrl/Secret/标题/内容/@列表,
/// AlertBridge 拉取后无需再查任何配置即可直接推送)
/// </summary>
[SugarColumn(ColumnDescription = "推送报文", ColumnDataType = "text", IsNullable = true)]
public string Payload { get; set; }
/// <summary>
/// 状态(1待推送/2已拉取/3成功/4失败)
/// </summary>
[SugarColumn(ColumnDescription = "状态")]
public OutboxStatusEnum Status { get; set; } = OutboxStatusEnum.Pending;
/// <summary>
/// 重试次数(AlertBridge 推送失败回写时累加)
/// </summary>
[SugarColumn(ColumnDescription = "重试次数")]
public int RetryCount { get; set; }
/// <summary>
/// 拉取时间
/// </summary>
[SugarColumn(ColumnDescription = "拉取时间", IsNullable = true)]
public DateTime? PullTime { get; set; }
/// <summary>
/// 完成时间(成功/失败回写时间)
/// </summary>
[SugarColumn(ColumnDescription = "完成时间", IsNullable = true)]
public DateTime? FinishTime { get; set; }
/// <summary>
/// 结果信息(失败原因等)
/// </summary>
[SugarColumn(ColumnDescription = "结果信息", Length = 500, IsNullable = true)]
public string ResultMsg { get; set; }
}
}
@@ -0,0 +1,89 @@
using SqlSugar;
using System;
namespace Model.Entity.Inspection
{
/// <summary>
/// 告警规则(规则实例:查看所有设备告警的规则列表)
/// </summary>
public class AlertRuleEntity : BaseEntity
{
/// <summary>
/// 归属类型(0=全局/旧数据;1=产品 2=设备)
/// </summary>
[SugarColumn(ColumnDescription = "归属类型", DefaultValue = "0")]
public byte OwnerType { get; set; }
/// <summary>
/// 归属对象IdProductEntity.Id / IotDeviceEntity.Id0 表示全局)
/// </summary>
[SugarColumn(ColumnDescription = "归属对象Id", DefaultValue = "0")]
public long OwnerId { get; set; }
/// <summary>
/// 绑定点编码(关联物模型点 Code;为空表示整产品/设备级规则)
/// </summary>
[SugarColumn(ColumnDescription = "绑定点编码", Length = 64, IsNullable = true)]
public string? PointCode { get; set; }
/// <summary>
/// 规则名称
/// </summary>
[SugarColumn(ColumnDescription = "规则名称", Length = 100)]
public string RuleName { get; set; }
/// <summary>
/// 适用设备类型(空表示全部设备,如 TemperatureBox 温度箱)
/// </summary>
[SugarColumn(ColumnDescription = "适用设备类型", Length = 64, IsNullable = true)]
public string DeviceType { get; set; }
/// <summary>
/// 监测指标(Temperature 温度 / Humidity 湿度等)
/// </summary>
[SugarColumn(ColumnDescription = "监测指标", Length = 64)]
public string Metric { get; set; }
/// <summary>
/// 比较运算符(> >= < <= = ≠)
/// </summary>
[SugarColumn(ColumnDescription = "比较运算符", Length = 10)]
public string Operator { get; set; }
/// <summary>
/// 阈值
/// </summary>
[SugarColumn(ColumnDescription = "阈值", IsNullable = true)]
public double? Threshold { get; set; }
/// <summary>
/// 告警级别(信息/警告/紧急)
/// </summary>
[SugarColumn(ColumnDescription = "告警级别", Length = 32)]
public string AlarmLevel { get; set; }
/// <summary>
/// 是否启用
/// </summary>
[SugarColumn(ColumnDescription = "是否启用")]
public bool Enabled { get; set; } = true;
/// <summary>
/// 触发次数(系统统计)
/// </summary>
[SugarColumn(ColumnDescription = "触发次数")]
public int TriggerCount { get; set; }
/// <summary>
/// 最近触发时间(系统统计)
/// </summary>
[SugarColumn(ColumnDescription = "最近触发时间", IsNullable = true)]
public DateTime? LastAlarmTime { get; set; }
/// <summary>
/// 备注
/// </summary>
[SugarColumn(ColumnDescription = "备注", Length = 500, IsNullable = true)]
public string Remark { get; set; }
}
}
@@ -0,0 +1,59 @@
using SqlSugar;
using System;
namespace Model.Entity.Inspection
{
/// <summary>
/// 数据转发规则(配置数据转发规则,支持通过消息通知的方式定时转发数据)
/// </summary>
public class DataForwardRuleEntity : BaseEntity
{
/// <summary>
/// 转发规则名称
/// </summary>
[SugarColumn(ColumnDescription = "转发规则名称", Length = 100)]
public string ForwardName { get; set; }
/// <summary>
/// 源设备类型(空表示全部设备,如 TemperatureBox 温度箱)
/// </summary>
[SugarColumn(ColumnDescription = "源设备类型", Length = 64, IsNullable = true)]
public string SourceDeviceType { get; set; }
/// <summary>
/// 转发间隔(秒)
/// </summary>
[SugarColumn(ColumnDescription = "转发间隔(秒)")]
public int ForwardInterval { get; set; } = 60;
/// <summary>
/// 通知方式(Email 邮件 / Sms 短信 / Http 接口推送)
/// </summary>
[SugarColumn(ColumnDescription = "通知方式", Length = 32)]
public string NotifyType { get; set; }
/// <summary>
/// 目标地址(邮箱 / 手机号 / 接口URL)
/// </summary>
[SugarColumn(ColumnDescription = "目标地址", Length = 200)]
public string TargetAddress { get; set; }
/// <summary>
/// 是否启用
/// </summary>
[SugarColumn(ColumnDescription = "是否启用")]
public bool Enabled { get; set; } = true;
/// <summary>
/// 最近转发时间(系统统计)
/// </summary>
[SugarColumn(ColumnDescription = "最近转发时间", IsNullable = true)]
public DateTime? LastForwardTime { get; set; }
/// <summary>
/// 备注
/// </summary>
[SugarColumn(ColumnDescription = "备注", Length = 500, IsNullable = true)]
public string Remark { get; set; }
}
}
@@ -0,0 +1,35 @@
using SqlSugar;
using System;
namespace Model.Entity.Inspection
{
/// <summary>
/// 值班排班(按天排班;通知规则勾选"通知值班人"时按告警日期匹配当班人)
/// </summary>
public class DutyRosterEntity : BaseEntity
{
/// <summary>
/// 值班日期(当天 00:00:00
/// </summary>
[SugarColumn(ColumnDescription = "值班日期")]
public DateTime DutyDate { get; set; }
/// <summary>
/// 值班人姓名
/// </summary>
[SugarColumn(ColumnDescription = "值班人", Length = 50)]
public string PersonName { get; set; }
/// <summary>
/// 联系电话(用于钉钉/企微 @ 手机号)
/// </summary>
[SugarColumn(ColumnDescription = "联系电话", Length = 30, IsNullable = true)]
public string Phone { get; set; }
/// <summary>
/// 备注
/// </summary>
[SugarColumn(ColumnDescription = "备注", Length = 500, IsNullable = true)]
public string Remark { get; set; }
}
}
@@ -0,0 +1,92 @@
using SqlSugar;
namespace Model.Entity.Inspection
{
/// <summary>
/// 设备维修记录
/// </summary>
public class RepairRecordEntity : BaseEntity
{
/// <summary>
/// 设备Id(关联 EquipmentEntity.Id
/// </summary>
public long EquipmentId { get; set; }
/// <summary>
/// 设备编号(冗余便于展示)
/// </summary>
[SugarColumn(Length = 50, IsNullable = true)]
public string? EquipmentCode { get; set; }
/// <summary>
/// 设备名称(冗余便于展示)
/// </summary>
[SugarColumn(Length = 100, IsNullable = true)]
public string? EquipmentName { get; set; }
/// <summary>
/// 故障类型(机械故障/电气故障/软件故障/传感器故障/其他)
/// </summary>
[SugarColumn(Length = 50, IsNullable = true)]
public string? FaultType { get; set; }
/// <summary>
/// 故障描述
/// </summary>
[SugarColumn(ColumnDataType = "text", IsNullable = true)]
public string? FaultDescription { get; set; }
/// <summary>
/// 报修日期
/// </summary>
public DateTime? ReportDate { get; set; }
/// <summary>
/// 维修开始日期
/// </summary>
[SugarColumn(IsNullable = true)]
public DateTime? RepairStartDate { get; set; }
/// <summary>
/// 维修完成日期
/// </summary>
[SugarColumn(IsNullable = true)]
public DateTime? RepairEndDate { get; set; }
/// <summary>
/// 维修工时(小时)
/// </summary>
[SugarColumn(IsNullable = true)]
public decimal? RepairHours { get; set; }
/// <summary>
/// 维修人员
/// </summary>
[SugarColumn(Length = 50, IsNullable = true)]
public string? RepairPerson { get; set; }
/// <summary>
/// 维修费用(元)
/// </summary>
[SugarColumn(DecimalDigits = 2, Length = 18, IsNullable = true)]
public decimal? RepairCost { get; set; }
/// <summary>
/// 维修状态(待维修/维修中/已完成)
/// </summary>
[SugarColumn(Length = 20, IsNullable = true)]
public string? RepairStatus { get; set; }
/// <summary>
/// 维修结果描述
/// </summary>
[SugarColumn(ColumnDataType = "text", IsNullable = true)]
public string? RepairResult { get; set; }
/// <summary>
/// 备注
/// </summary>
[SugarColumn(Length = 500, IsNullable = true)]
public string? Remark { get; set; }
}
}
+50
View File
@@ -0,0 +1,50 @@
using SqlSugar;
namespace Model.Entity.System
{
/// <summary>
/// 审计日志表(记录所有关键操作)
/// </summary>
[SugarTable("sys_audit_log")]
public class AuditLogEntity : BaseEntity
{
[SugarColumn(ColumnName = "UserId", ColumnDescription = "操作人用户Id")]
public long UserId { get; set; }
[SugarColumn(ColumnName = "UserName", ColumnDescription = "操作人用户名", Length = 64, IsNullable = true)]
public string? UserName { get; set; }
[SugarColumn(ColumnName = "RoleId", ColumnDescription = "操作人角色Id0=无/多角色)", DefaultValue = "0")]
public long RoleId { get; set; }
[SugarColumn(ColumnName = "RoleName", ColumnDescription = "操作人角色名称", Length = 100, IsNullable = true)]
public string? RoleName { get; set; }
[SugarColumn(ColumnName = "OperationType", ColumnDescription = "操作类型(Create/Update/Delete/Login/Command等)", Length = 32, IsNullable = false)]
public string OperationType { get; set; }
[SugarColumn(ColumnName = "OperationTarget", ColumnDescription = "操作对象(如 IotDevice/Gateway/User/Role", Length = 64, IsNullable = false)]
public string OperationTarget { get; set; }
[SugarColumn(ColumnName = "TargetId", ColumnDescription = "操作对象Id0=无具体Id", DefaultValue = "0")]
public long TargetId { get; set; }
[SugarColumn(ColumnName = "DeviceId", ColumnDescription = "关联设备Id0=无关设备)", DefaultValue = "0")]
public long DeviceId { get; set; }
[SugarColumn(ColumnName = "OldValue", ColumnDescription = "操作前值(JSON", ColumnDataType = "text", IsNullable = true)]
public string? OldValue { get; set; }
[SugarColumn(ColumnName = "NewValue", ColumnDescription = "操作后值(JSON", ColumnDataType = "text", IsNullable = true)]
public string? NewValue { get; set; }
[SugarColumn(ColumnName = "Ip", ColumnDescription = "操作IP", Length = 45, IsNullable = true)]
public string? Ip { get; set; }
[SugarColumn(ColumnName = "Description", ColumnDescription = "操作描述", Length = 500, IsNullable = true)]
public string? Description { get; set; }
[SugarColumn(ColumnName = "OperateTime", ColumnDescription = "操作时间", IsNullable = false)]
public DateTime OperateTime { get; set; }
}
}
+32
View File
@@ -0,0 +1,32 @@
using SqlSugar;
namespace Model.Entity.System
{
/// <summary>
/// 数据字典项表(某分类下的具体枚举值)
/// </summary>
[SugarTable("sys_dict_item")]
public class DictItemEntity : BaseEntity
{
[SugarColumn(ColumnName = "TypeId", ColumnDescription = "所属字典分类Id")]
public long TypeId { get; set; }
[SugarColumn(ColumnName = "Code", ColumnDescription = "字典项编码", Length = 64, IsNullable = false)]
public string Code { get; set; }
[SugarColumn(ColumnName = "Name", ColumnDescription = "字典项文本", Length = 100, IsNullable = false)]
public string Name { get; set; }
[SugarColumn(ColumnName = "Sort", ColumnDescription = "排序号", DefaultValue = "0")]
public int Sort { get; set; }
[SugarColumn(ColumnName = "Status", ColumnDescription = "状态(1=启用,0=停用)", ColumnDataType = "smallint", DefaultValue = "1")]
public byte Status { get; set; }
[SugarColumn(ColumnName = "IsDefault", ColumnDescription = "是否默认选中(同分类下唯一)", ColumnDataType = "smallint", DefaultValue = "0")]
public byte IsDefault { get; set; }
[SugarColumn(ColumnName = "Remark", ColumnDescription = "备注", Length = 500, IsNullable = true)]
public string? Remark { get; set; }
}
}
+26
View File
@@ -0,0 +1,26 @@
using SqlSugar;
namespace Model.Entity.System
{
/// <summary>
/// 数据字典分类表(如:设备类型、故障类型、告警级别、工单优先级)
/// </summary>
[SugarTable("sys_dict_type")]
public class DictTypeEntity : BaseEntity
{
[SugarColumn(ColumnName = "Code", ColumnDescription = "字典编码(唯一)", Length = 64, IsNullable = false)]
public string Code { get; set; }
[SugarColumn(ColumnName = "Name", ColumnDescription = "字典名称", Length = 100, IsNullable = false)]
public string Name { get; set; }
[SugarColumn(ColumnName = "Status", ColumnDescription = "状态(1=启用,0=停用)", ColumnDataType = "smallint", DefaultValue = "1")]
public byte Status { get; set; }
[SugarColumn(ColumnName = "Sort", ColumnDescription = "排序号", DefaultValue = "0")]
public int Sort { get; set; }
[SugarColumn(ColumnName = "Remark", ColumnDescription = "备注", Length = 500, IsNullable = true)]
public string? Remark { get; set; }
}
}
+50
View File
@@ -0,0 +1,50 @@
using SqlSugar;
namespace Model.Entity.System
{
/// <summary>
/// 文件上传记录表(每次上传都登记:来源 Provider、业务类型、访问 URL
/// </summary>
[SugarTable("sys_file_record")]
public class FileRecordEntity : BaseEntity
{
/// <summary>存储后的文件名(含相对路径,如 2026/09/18/snowflake.jpg</summary>
[SugarColumn(ColumnName = "FileName", ColumnDescription = "存储文件名(含相对路径)", Length = 255, IsNullable = false)]
public string FileName { get; set; }
/// <summary>原始文件名(用户上传时的文件名)</summary>
[SugarColumn(ColumnName = "OriginalName", ColumnDescription = "原始文件名", Length = 255, IsNullable = true)]
public string? OriginalName { get; set; }
/// <summary>访问 URLEndpoint + 相对路径)</summary>
[SugarColumn(ColumnName = "Url", ColumnDescription = "访问URL", Length = 500, IsNullable = true)]
public string? Url { get; set; }
/// <summary>文件大小(字节)</summary>
[SugarColumn(ColumnName = "Size", ColumnDescription = "文件大小(字节)")]
public long Size { get; set; }
/// <summary>扩展名(含点,如 .jpg</summary>
[SugarColumn(ColumnName = "Ext", ColumnDescription = "扩展名", Length = 20, IsNullable = true)]
public string? Ext { get; set; }
/// <summary>使用的存储 Provider(冗余,便于排查)</summary>
[SugarColumn(ColumnName = "Provider", ColumnDescription = "存储Provider", Length = 20, IsNullable = true)]
public string? Provider { get; set; }
/// <summary>使用的存储配置Id(关联 sys_file_storage.Id</summary>
public long StorageId { get; set; }
/// <summary>上传人</summary>
[SugarColumn(ColumnName = "Uploader", ColumnDescription = "上传人", Length = 50, IsNullable = true)]
public string? Uploader { get; set; }
/// <summary>业务类型(equipment_attachment/qrcode/generic 等,便于按业务查询)</summary>
[SugarColumn(ColumnName = "BizType", ColumnDescription = "业务类型", Length = 50, IsNullable = true)]
public string? BizType { get; set; }
/// <summary>业务Id(关联业务实体的 Id,可空)</summary>
[SugarColumn(ColumnName = "BizId", ColumnDescription = "业务Id", Length = 64, IsNullable = true)]
public string? BizId { get; set; }
}
}
@@ -0,0 +1,64 @@
using SqlSugar;
namespace Model.Entity.System
{
/// <summary>
/// 文件存储配置表(多 Provider 配置:Local/MinIO/OSS,仅 Local 落地,其它留扩展点)
/// 一个 IsDefault=1 的配置为默认上传通道;AccessKey/SecretKey 对 Local 为空
/// </summary>
[SugarTable("sys_file_storage")]
public class FileStorageConfigEntity : BaseEntity
{
/// <summary>存储 Providerlocal/minio/oss</summary>
[SugarColumn(ColumnName = "Provider", ColumnDescription = "存储Provider(local/minio/oss)", Length = 20, IsNullable = false)]
public string Provider { get; set; }
/// <summary>配置名称(如:本地存储 / MinIO测试)</summary>
[SugarColumn(ColumnName = "Name", ColumnDescription = "配置名称", Length = 100, IsNullable = false)]
public string Name { get; set; }
/// <summary>访问端点(Local 为 URL 前缀如 http://host/uploads 或 /uploadsMinIO 为 http://minio:9000</summary>
[SugarColumn(ColumnName = "Endpoint", ColumnDescription = "访问端点", Length = 255, IsNullable = true)]
public string? Endpoint { get; set; }
/// <summary>访问 KeyLocal 为空;MinIO/OSS 为 AccessKey。注:当前明文存储,接入 MinIO/OSS 时应改为 AES 加密)</summary>
[SugarColumn(ColumnName = "AccessKey", ColumnDescription = "AccessKey(Local为空)", Length = 128, IsNullable = true)]
public string? AccessKey { get; set; }
/// <summary>密钥(Local 为空;MinIO/OSS 为 SecretKey。注:当前明文存储,接入 MinIO/OSS 时应改为 AES 加密)</summary>
[SugarColumn(ColumnName = "SecretKey", ColumnDescription = "SecretKey(Local为空)", Length = 255, IsNullable = true)]
public string? SecretKey { get; set; }
/// <summary>桶名(Local 为 BasePath 下子目录;MinIO/OSS 为 bucket 名)</summary>
[SugarColumn(ColumnName = "Bucket", ColumnDescription = "桶名", Length = 100, IsNullable = true)]
public string? Bucket { get; set; }
/// <summary>区域(OSS 用,Local/MinIO 为空)</summary>
[SugarColumn(ColumnName = "Region", ColumnDescription = "区域(OSS用)", Length = 50, IsNullable = true)]
public string? Region { get; set; }
/// <summary>本地存储根路径(Local 专用,如 wwwroot/uploadsMinIO/OSS 为空)</summary>
[SugarColumn(ColumnName = "BasePath", ColumnDescription = "本地存储根路径(Local专用)", Length = 255, IsNullable = true)]
public string? BasePath { get; set; }
/// <summary>单个文件大小上限(MB</summary>
[SugarColumn(ColumnName = "MaxSizeMB", ColumnDescription = "单个文件大小上限(MB)", DefaultValue = "10")]
public int MaxSizeMB { get; set; } = 10;
/// <summary>允许的扩展名(JSON 数组,如 [".jpg",".png"];为空表示不限制)</summary>
[SugarColumn(ColumnName = "AllowedExts", ColumnDescription = "允许扩展名(JSON数组)", ColumnDataType = "text", IsNullable = true)]
public string? AllowedExts { get; set; }
/// <summary>是否默认(1=默认上传通道,全局唯一)</summary>
[SugarColumn(ColumnName = "IsDefault", ColumnDescription = "是否默认(1=默认,0=否)", ColumnDataType = "smallint", DefaultValue = "0")]
public byte IsDefault { get; set; }
/// <summary>状态(1=启用,0=停用)</summary>
[SugarColumn(ColumnName = "Status", ColumnDescription = "状态(1=启用,0=停用)", ColumnDataType = "smallint", DefaultValue = "1")]
public byte Status { get; set; } = 1;
/// <summary>备注</summary>
[SugarColumn(ColumnName = "Remark", ColumnDescription = "备注", Length = 500, IsNullable = true)]
public string? Remark { get; set; }
}
}
+40
View File
@@ -0,0 +1,40 @@
using SqlSugar;
namespace Model.Entity.System
{
/// <summary>
/// 组织架构表(树形:公司/实验室/部门/班组,ParentId 自关联;班组带白/夜班)
/// </summary>
[SugarTable("sys_org")]
public class OrgEntity : BaseEntity
{
[SugarColumn(ColumnName = "ParentId", ColumnDescription = "父级组织Id0=根节点)", DefaultValue = "0")]
public long ParentId { get; set; }
[SugarColumn(ColumnName = "Name", ColumnDescription = "组织名称", Length = 100, IsNullable = false)]
public string Name { get; set; }
[SugarColumn(ColumnName = "Code", ColumnDescription = "组织编码(唯一)", Length = 64, IsNullable = false)]
public string Code { get; set; }
/// <summary>组织类型:1=公司, 2=实验室, 3=部门, 4=班组</summary>
[SugarColumn(ColumnName = "OrgType", ColumnDescription = "组织类型(1公司/2实验室/3部门/4班组)", ColumnDataType = "smallint", DefaultValue = "3")]
public byte OrgType { get; set; }
/// <summary>班组班次:0=非班组, 1=白班, 2=夜班</summary>
[SugarColumn(ColumnName = "ShiftType", ColumnDescription = "班次(0非班组/1白班/2夜班)", ColumnDataType = "smallint", DefaultValue = "0")]
public byte ShiftType { get; set; }
[SugarColumn(ColumnName = "Leader", ColumnDescription = "负责人", Length = 50, IsNullable = true)]
public string? Leader { get; set; }
[SugarColumn(ColumnName = "Phone", ColumnDescription = "联系电话", Length = 20, IsNullable = true)]
public string? Phone { get; set; }
[SugarColumn(ColumnName = "Sort", ColumnDescription = "排序号", DefaultValue = "0")]
public int Sort { get; set; }
[SugarColumn(ColumnName = "Remark", ColumnDescription = "备注", Length = 500, IsNullable = true)]
public string? Remark { get; set; }
}
}
+26
View File
@@ -0,0 +1,26 @@
using SqlSugar;
namespace Model.Entity.System
{
/// <summary>
/// 权限表(功能权限码,如 device:view / device:edit / alert:confirm
/// </summary>
[SugarTable("sys_permission")]
public class PermissionEntity : BaseEntity
{
[SugarColumn(ColumnName = "Code", ColumnDescription = "权限编码(唯一,如 device:view", Length = 64, IsNullable = false)]
public string Code { get; set; }
[SugarColumn(ColumnName = "Name", ColumnDescription = "权限名称", Length = 100, IsNullable = false)]
public string Name { get; set; }
[SugarColumn(ColumnName = "Group", ColumnDescription = "权限分组(如 device/alert/inspection/user", Length = 64, IsNullable = true)]
public string? Group { get; set; }
[SugarColumn(ColumnName = "IsSystem", ColumnDescription = "是否系统内置(不可删除)", ColumnDataType = "smallint", DefaultValue = "0")]
public byte IsSystem { get; set; }
[SugarColumn(ColumnName = "Sort", ColumnDescription = "排序号", DefaultValue = "0")]
public int Sort { get; set; }
}
}
+29
View File
@@ -0,0 +1,29 @@
using SqlSugar;
namespace Model.Entity.System
{
/// <summary>
/// 角色表
/// </summary>
[SugarTable("sys_role")]
public class RoleEntity : BaseEntity
{
[SugarColumn(ColumnName = "Code", ColumnDescription = "角色编码(唯一)", Length = 64, IsNullable = false)]
public string Code { get; set; }
[SugarColumn(ColumnName = "Name", ColumnDescription = "角色名称", Length = 100, IsNullable = false)]
public string Name { get; set; }
[SugarColumn(ColumnName = "DataScope", ColumnDescription = "数据范围(1=全部,2=本实验室)", ColumnDataType = "smallint", DefaultValue = "2")]
public byte DataScope { get; set; }
[SugarColumn(ColumnName = "IsSystem", ColumnDescription = "是否系统内置角色(不可删除)", ColumnDataType = "smallint", DefaultValue = "0")]
public byte IsSystem { get; set; }
[SugarColumn(ColumnName = "Sort", ColumnDescription = "排序号", DefaultValue = "0")]
public int Sort { get; set; }
[SugarColumn(ColumnName = "Remark", ColumnDescription = "备注", Length = 500, IsNullable = true)]
public string? Remark { get; set; }
}
}
@@ -0,0 +1,17 @@
using SqlSugar;
namespace Model.Entity.System
{
/// <summary>
/// 角色-权限关联表(多对多)
/// </summary>
[SugarTable("sys_role_permission")]
public class RolePermissionEntity : BaseEntity
{
[SugarColumn(ColumnName = "RoleId", ColumnDescription = "角色Id")]
public long RoleId { get; set; }
[SugarColumn(ColumnName = "PermissionId", ColumnDescription = "权限Id")]
public long PermissionId { get; set; }
}
}
+60
View File
@@ -0,0 +1,60 @@
using SqlSugar;
namespace Model.Entity.System
{
/// <summary>
/// 系统参数配置表(键值对形式存储业务可调参数,按 Group 分组展示)
/// 内置参数(IsSystem=1)禁止删除,仅允许修改 Value
/// </summary>
[SugarTable("sys_param")]
public class SystemParamEntity : BaseEntity
{
/// <summary>参数键(唯一,业务侧按 Key 取值,如 data_collect_default_frequency</summary>
[SugarColumn(ColumnName = "ParamKey", ColumnDescription = "参数键(唯一)", Length = 64, IsNullable = false)]
public string ParamKey { get; set; }
/// <summary>参数名称(中文展示名)</summary>
[SugarColumn(ColumnName = "ParamName", ColumnDescription = "参数名称", Length = 100, IsNullable = false)]
public string ParamName { get; set; }
/// <summary>参数值(统一字符串存储,业务侧按 ParamType 自行转换)</summary>
[SugarColumn(ColumnName = "ParamValue", ColumnDescription = "参数值", ColumnDataType = "text", IsNullable = true)]
public string? ParamValue { get; set; }
/// <summary>值类型(0=int, 1=text, 2=enum, 3=json)决定前端渲染控件</summary>
[SugarColumn(ColumnName = "ParamType", ColumnDescription = "值类型(0=int,1=text,2=enum,3=json", ColumnDataType = "smallint", DefaultValue = "1")]
public byte ParamType { get; set; }
/// <summary>枚举选项(JSON 数组,如 [{"value":"feishu","label":"飞书"}];仅 ParamType=enum 使用)</summary>
[SugarColumn(ColumnName = "ParamOptions", ColumnDescription = "枚举选项(JSON数组)", ColumnDataType = "text", IsNullable = true)]
public string? ParamOptions { get; set; }
/// <summary>单位(如 秒/MB,前端展示用)</summary>
[SugarColumn(ColumnName = "Unit", ColumnDescription = "单位", Length = 20, IsNullable = true)]
public string? Unit { get; set; }
/// <summary>分组(如 数据采集/告警/工单/文件,前端按分组卡片展示)</summary>
[SugarColumn(ColumnName = "Group", ColumnDescription = "分组", Length = 50, IsNullable = true)]
public string? Group { get; set; }
/// <summary>排序号</summary>
[SugarColumn(ColumnName = "Sort", ColumnDescription = "排序号", DefaultValue = "0")]
public int Sort { get; set; }
/// <summary>备注(注释提示,如工单编号规则占位符说明)</summary>
[SugarColumn(ColumnName = "Remark", ColumnDescription = "备注", Length = 500, IsNullable = true)]
public string? Remark { get; set; }
/// <summary>是否系统内置(1=内置不可删,0=用户自定义)</summary>
[SugarColumn(ColumnName = "IsSystem", ColumnDescription = "是否系统内置(1=不可删,0=可删)", ColumnDataType = "smallint", DefaultValue = "0")]
public byte IsSystem { get; set; }
/// <summary>最后修改人</summary>
[SugarColumn(ColumnName = "LastUpdateUser", ColumnDescription = "最后修改人", Length = 50, IsNullable = true)]
public string? LastUpdateUser { get; set; }
/// <summary>最后修改时间</summary>
[SugarColumn(ColumnName = "LastUpdateTime", ColumnDescription = "最后修改时间", IsNullable = true)]
public DateTime? LastUpdateTime { get; set; }
}
}
+47
View File
@@ -0,0 +1,47 @@
using SqlSugar;
namespace Model.Entity.System
{
/// <summary>
/// 用户表
/// </summary>
[SugarTable("sys_user")]
public class UserEntity : BaseEntity
{
[SugarColumn(ColumnName = "UserName", ColumnDescription = "用户名(登录账号)", Length = 64, IsNullable = false)]
public string UserName { get; set; }
[SugarColumn(ColumnName = "PasswordHash", ColumnDescription = "密码哈希", Length = 256, IsNullable = false)]
public string PasswordHash { get; set; }
[SugarColumn(ColumnName = "RealName", ColumnDescription = "真实姓名", Length = 100, IsNullable = true)]
public string? RealName { get; set; }
[SugarColumn(ColumnName = "Email", ColumnDescription = "邮箱", Length = 128, IsNullable = true)]
public string? Email { get; set; }
[SugarColumn(ColumnName = "Phone", ColumnDescription = "手机号", Length = 20, IsNullable = true)]
public string? Phone { get; set; }
[SugarColumn(ColumnName = "OrgId", ColumnDescription = "所属组织Id(公司/实验室/部门/班组任一节点,0=未分配)", DefaultValue = "0")]
public long OrgId { get; set; }
[SugarColumn(ColumnName = "Position", ColumnDescription = "岗位", Length = 64, IsNullable = true)]
public string? Position { get; set; }
[SugarColumn(ColumnName = "SkillTags", ColumnDescription = "技能标签(逗号分隔,如 Modbus,PLC,温度箱)", Length = 500, IsNullable = true)]
public string? SkillTags { get; set; }
[SugarColumn(ColumnName = "IsEnabled", ColumnDescription = "是否启用", ColumnDataType = "smallint", DefaultValue = "1")]
public byte IsEnabled { get; set; }
[SugarColumn(ColumnName = "LastLoginTime", ColumnDescription = "最后登录时间", IsNullable = true)]
public DateTime? LastLoginTime { get; set; }
[SugarColumn(ColumnName = "Avatar", ColumnDescription = "头像URL", Length = 256, IsNullable = true)]
public string? Avatar { get; set; }
[SugarColumn(ColumnName = "Remark", ColumnDescription = "备注", Length = 500, IsNullable = true)]
public string? Remark { get; set; }
}
}
+17
View File
@@ -0,0 +1,17 @@
using SqlSugar;
namespace Model.Entity.System
{
/// <summary>
/// 用户-角色关联表(多对多)
/// </summary>
[SugarTable("sys_user_role")]
public class UserRoleEntity : BaseEntity
{
[SugarColumn(ColumnName = "UserId", ColumnDescription = "用户Id")]
public long UserId { get; set; }
[SugarColumn(ColumnName = "RoleId", ColumnDescription = "角色Id")]
public long RoleId { get; set; }
}
}
File diff suppressed because it is too large Load Diff
+217 -2
View File
@@ -1,12 +1,227 @@
using Model;
using Model.Dto.Asset;
using Model.Entity.Asset;
using ORM;
using QRCoder;
using Service.Interface;
using SqlSugar;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Service.Implement
{
/// <summary>
/// 二维码 服务实现
/// 设备二维码 服务实现
/// 扫码直达 URL 约定:{baseUrl}/asset/ledger/equipment?id={equipmentId}
/// baseUrl 由 Controller 从 HTTP 请求(scheme://host)传入;如需固定域名可改为读 sys_param 的 site_base_url
/// </summary>
public class QrCodeService : IQrCodeService
{
// TODO: 实现 二维码 相关方法
public async Task<Result<QrCodeDto>> GenerateAsync(long equipmentId, string baseUrl)
{
if (equipmentId <= 0) return Result<QrCodeDto>.Error("设备Id无效");
try
{
var entity = await LoadEquipmentAsync(equipmentId);
if (entity == null) return Result<QrCodeDto>.Error("设备不存在或已被删除");
var url = BuildQrUrl(baseUrl, equipmentId);
if (string.IsNullOrEmpty(entity.QrCodeUrl))
{
await SqlSugarContext.DbContext.Updateable<EquipmentEntity>()
.SetColumns(x => x.QrCodeUrl == url)
.Where(x => x.Id == equipmentId).ExecuteCommandAsync();
entity.QrCodeUrl = url;
}
return Result<QrCodeDto>.Success(ToDto(entity));
}
catch (Exception ex)
{
return Result<QrCodeDto>.Error("生成二维码失败", ex);
}
}
public async Task<Result<byte[]>> GenerateImageAsync(long equipmentId, string baseUrl)
{
if (equipmentId <= 0) return Result<byte[]>.Error("设备Id无效");
try
{
var entity = await LoadEquipmentAsync(equipmentId);
if (entity == null) return Result<byte[]>.Error("设备不存在或已被删除");
var url = string.IsNullOrEmpty(entity.QrCodeUrl) ? BuildQrUrl(baseUrl, equipmentId) : entity.QrCodeUrl;
if (string.IsNullOrEmpty(entity.QrCodeUrl))
{
await SqlSugarContext.DbContext.Updateable<EquipmentEntity>()
.SetColumns(x => x.QrCodeUrl == url)
.Where(x => x.Id == equipmentId).ExecuteCommandAsync();
entity.QrCodeUrl = url;
}
var png = EncodePng(url);
return Result<byte[]>.Success(png);
}
catch (Exception ex)
{
return Result<byte[]>.Error("生成二维码图片失败", ex);
}
}
public async Task<Result<List<QrCodeBatchResultDto>>> BatchGenerateAsync(long[] equipmentIds, string baseUrl)
{
if (equipmentIds == null || equipmentIds.Length == 0)
return Result<List<QrCodeBatchResultDto>>.Error("设备Id列表不能为空");
var results = new List<QrCodeBatchResultDto>();
foreach (var id in equipmentIds.Distinct())
{
var r = await GenerateAsync(id, baseUrl);
results.Add(new QrCodeBatchResultDto
{
EquipmentId = id.ToString(),
EquipmentCode = r.Data?.EquipmentCode,
EquipmentName = r.Data?.EquipmentName,
QrCodeUrl = r.Data?.QrCodeUrl,
Success = r.IsSuccess,
Message = r.IsSuccess ? "成功" : r.Msg
});
}
return Result<List<QrCodeBatchResultDto>>.Success(results);
}
public async Task<Result<List<QrCodeDto>>> GetListPagedAsync(int pageIndex, int pageSize, RefAsync<int> total, string? keyword = null, bool qrOnly = false)
{
try
{
var list = await SqlSugarContext.DbContext.Queryable<EquipmentEntity>()
.Where(x => x.IsDel == 0)
.WhereIF(!string.IsNullOrWhiteSpace(keyword), x => x.Code!.Contains(keyword!) || x.Name!.Contains(keyword!))
.WhereIF(qrOnly, x => x.QrCodeUrl != null && x.QrCodeUrl != "")
.OrderBy(x => x.Code)
.ToPageListAsync(pageIndex, pageSize, total);
return Result<List<QrCodeDto>>.Success(list.Select(ToDto).ToList());
}
catch (Exception ex)
{
return Result<List<QrCodeDto>>.Error("查询二维码列表失败", ex);
}
}
public async Task<Result<string>> GetPrintHtmlAsync(long equipmentId, string baseUrl)
{
if (equipmentId <= 0) return Result<string>.Error("设备Id无效");
try
{
var entity = await LoadEquipmentAsync(equipmentId);
if (entity == null) return Result<string>.Error("设备不存在或已被删除");
var url = string.IsNullOrEmpty(entity.QrCodeUrl) ? BuildQrUrl(baseUrl, equipmentId) : entity.QrCodeUrl;
var png = EncodePng(url);
var base64 = Convert.ToBase64String(png);
var html = BuildPrintHtml(entity, url, base64);
return Result<string>.Success(html);
}
catch (Exception ex)
{
return Result<string>.Error("生成打印页失败", ex);
}
}
public async Task<Result> BindRfidAsync(long equipmentId, string rfidCode)
{
if (equipmentId <= 0) return Result.Error("设备Id无效");
if (string.IsNullOrWhiteSpace(rfidCode)) return Result.Error("RFID 编号不能为空");
try
{
var exists = await SqlSugarContext.DbContext.Queryable<EquipmentEntity>()
.Where(x => x.Id == equipmentId && x.IsDel == 0).AnyAsync();
if (!exists) return Result.Error("设备不存在或已被删除");
await SqlSugarContext.DbContext.Updateable<EquipmentEntity>()
.SetColumns(x => x.RfidCode == rfidCode)
.Where(x => x.Id == equipmentId).ExecuteCommandAsync();
return Result.Success();
}
catch (Exception ex)
{
return Result.Error("绑定 RFID 失败", ex);
}
}
// ==================== 私有工具 ====================
private static async Task<EquipmentEntity?> LoadEquipmentAsync(long id)
=> await SqlSugarContext.DbContext.Queryable<EquipmentEntity>()
.Where(x => x.Id == id && x.IsDel == 0).FirstAsync();
private static QrCodeDto ToDto(EquipmentEntity e) => new()
{
EquipmentId = e.Id.ToString(),
EquipmentCode = e.Code,
EquipmentName = e.Name,
Location = e.Location,
QrCode = e.QrCode,
QrCodeUrl = e.QrCodeUrl,
RfidCode = e.RfidCode
};
private static string BuildQrUrl(string baseUrl, long equipmentId)
{
var b = (baseUrl ?? string.Empty).TrimEnd('/');
return $"{b}/asset/ledger/equipment?id={equipmentId}";
}
private static byte[] EncodePng(string content)
{
using var gen = new QRCodeGenerator();
var data = gen.CreateQrCode(content, QRCodeGenerator.ECCLevel.Q);
var png = new PngByteQRCode(data);
return png.GetGraphic(20); // 20 px per module,便于扫描识别
}
private static string BuildPrintHtml(EquipmentEntity e, string url, string base64)
{
// 简单 A4 打印模板:左侧二维码,右侧设备信息;浏览器 window.print() 即可
var safe = (string? s) => System.Net.WebUtility.HtmlEncode(s ?? string.Empty);
return $@"<!DOCTYPE html>
<html lang='zh-CN'>
<head>
<meta charset='utf-8' />
<title>设备二维码 - {safe(e.Name)}</title>
<style>
@page {{ size: A4; margin: 12mm; }}
body {{ font-family: 'Microsoft YaHei', sans-serif; color: #303133; }}
.label {{ border:1px solid #303133; border-radius:6px; padding:16px; display:flex; gap:24px; align-items:center; max-width:480px; }}
.qr img {{ width: 220px; height: 220px; }}
.info dl {{ margin:0; }}
.info dt {{ font-size:13px; color:#909399; margin-top:8px; }}
.info dd {{ font-size:16px; margin:4px 0 0 0; font-weight:600; }}
.info h2 {{ margin:0 0 8px 0; font-size:20px; }}
.url {{ margin-top:12px; font-size:12px; color:#909399; word-break:break-all; }}
.actions {{ margin-top:16px; }}
.actions button {{ padding:6px 16px; font-size:14px; cursor:pointer; }}
</style>
</head>
<body>
<div class='label'>
<div class='qr'><img src='data:image/png;base64,{base64}' alt='QR' /></div>
<div class='info'>
<h2>{safe(e.Name)}</h2>
<dl>
<dt>设备编号</dt><dd>{safe(e.Code)}</dd>
<dt>二维码编号</dt><dd>{safe(e.QrCode)}</dd>
<dt>存放位置</dt><dd>{safe(e.Location)}</dd>
<dt>责任人</dt><dd>{safe(e.ResponsiblePerson)}</dd>
</dl>
<div class='url'>{safe(url)}</div>
</div>
</div>
<div class='actions'><button onclick='window.print()'>打印</button></div>
<script>window.onload=function(){{setTimeout(function(){{window.print();}},300);}};</script>
</body>
</html>";
}
}
}
@@ -0,0 +1,187 @@
using DeviceCommand.Base;
using Model;
using Model.Dto.Config;
using Model.Entity.Config;
using ORM;
using Service.Interface;
using Service.Interface.Config;
using SqlSugar;
using System;
using System.Net;
using System.Threading.Tasks;
namespace Service.Implement.Config
{
/// <summary>
/// 设备指令下发 服务实现
/// 目前无网关运行时/采集引擎:每次调用先落一条设备日志(始终可验证),
/// 设备在线时才实际尝试 Modbus 写(连接参数从网关表按 GatewayId 加载)。
/// 每次下发都写审计日志(操作人/角色/时间/前后值),满足实验室审计要求。
/// </summary>
public class DeviceCommandService : IDeviceCommandService
{
private readonly IAuditRecorder _audit;
public DeviceCommandService(IAuditRecorder audit)
{
_audit = audit;
}
public async Task<Result> SendCommandAsync(DeviceCommandDto dto)
{
if (dto == null || !long.TryParse(dto.Id, out var deviceId) || deviceId <= 0)
return Result.Error("设备Id无效");
if (string.IsNullOrWhiteSpace(dto.PointId) || !long.TryParse(dto.PointId, out var pointId) || pointId <= 0)
return Result.Error("物模型点Id无效");
var now = DateTime.Now;
// 载入设备与点
var device = await SqlSugarContext.DbContext.Queryable<IotDeviceEntity>()
.Where(x => x.Id == deviceId && x.IsDel == 0).FirstAsync();
if (device == null)
return Result.Error("设备不存在或已被删除");
var point = await SqlSugarContext.DbContext.Queryable<ThingModelPointEntity>()
.Where(x => x.Id == pointId && x.OwnerType == ThingOwnerTypeEnum.Device && x.OwnerId == deviceId && x.IsDel == 0).FirstAsync();
if (point == null)
return Result.Error("物模型点不存在或不属于该设备");
// 校验可写
if (point.Rw == ThingRwEnum.ReadOnly)
return Result.Error($"点【{point.Name ?? point.Code}】为只读,无法下发指令");
if (device.ProtocolType != IotDeviceProtocolEnum.ModbusTcp)
return Result.Error("当前仅支持 Modbus TCP 协议设备下发指令");
try
{
// 模拟模式 / 设备离线 → 只记录、不发网络报文
if (dto.IsSimulated || device.OnlineStatus != IotDeviceOnlineStatusEnum.Online)
{
await WriteLogAsync(device, "Warn", "指令", dto.IsSimulated
? $"【模拟】点 {point.Name ?? point.Code} 下发值 {dto.Value}(未发送网络报文)"
: $"设备离线,指令已记录但未发送(点 {point.Name ?? point.Code} 值 {dto.Value}");
return dto.IsSimulated
? Result.Success()
: Result.Error("设备离线,指令已记录但未发送");
}
// 从网关表加载连接参数
var gateway = await SqlSugarContext.DbContext.Queryable<GatewayEntity>()
.Where(x => x.Id == device.GatewayId && x.IsDel == 0).FirstAsync();
if (gateway == null)
return Result.Error($"设备关联的网关不存在(GatewayId={device.GatewayId}");
if (string.IsNullOrWhiteSpace(gateway.Host) || !gateway.Port.HasValue)
return Result.Error($"网关「{gateway.Code}」未配置 IP 地址或端口号");
var host = gateway.Host!;
var port = gateway.Port!.Value;
// 实际 Modbus 写
using var modbus = new ModbusTcp();
modbus.ConfigureDevice(host, port, 3000, 3000);
bool connected = await modbus.ConnectAsync();
if (!connected)
{
await WriteLogAsync(device, "Error", "指令",
$"网关 {gateway.Code}({host}:{port}) 连接失败,指令未发送(点 {point.Name ?? point.Code} 值 {dto.Value}");
return Result.Error($"网关 {gateway.Code} 连接失败,指令未发送");
}
string desc;
if (point.RegisterType == ThingRegisterTypeEnum.Coil)
{
bool coil = dto.Value != 0;
await modbus.WriteSingleCoilAsync(device.SlaveId, point.Address, coil);
desc = $"写线圈 {point.Name ?? point.Code} 地址 {point.Address} = {coil}";
}
else if (point.DataType == ThingDataTypeEnum.Int32 || point.DataType == ThingDataTypeEnum.Float)
{
// 32 位类型占连续 2 个寄存器,走 FC16 连写(Int32 按有符号整数编码,Float 按 IEEE754 编码)
ushort[] words = ToWords(dto.Value, point);
await modbus.WriteMultipleRegistersAsync(device.SlaveId, point.Address, words);
desc = $"写双寄存器 {point.Name ?? point.Code} 地址 {point.Address}~{point.Address + 1} = [{words[0]}, {words[1]}](工程值 {dto.Value}";
}
else
{
ushort raw = ToRaw(dto.Value, point);
await modbus.WriteSingleRegisterAsync(device.SlaveId, point.Address, raw);
desc = $"写寄存器 {point.Name ?? point.Code} 地址 {point.Address} = {raw}(工程值 {dto.Value}";
}
await SqlSugarContext.DbContext.Updateable<IotDeviceEntity>()
.SetColumns(x => new IotDeviceEntity { LastCollectTime = now, LastError = null })
.Where(x => x.Id == device.Id).ExecuteCommandAsync();
await WriteLogAsync(device, "Info", "指令", $"下发成功:{desc}");
await _audit.RecordAsync("Command", "IotDevice", device.Id, device.Id,
oldValue: null,
newValue: $"点={point.Code}, 值={dto.Value}, 模拟={dto.IsSimulated}",
description: $"设备 {device.Code} 下发指令:{desc}");
return Result.Success();
}
catch (Exception ex)
{
await WriteLogAsync(device, "Error", "指令", $"下发失败:{ex.Message}");
return Result.Error($"指令下发异常:{ex.Message}", ex);
}
}
/// <summary>
/// 工程值 → 寄存器原始值:raw = (value - Offset) / Scale,取整并夹到寄存器范围
/// Int16 按有符号处理(补码),其余类型按 0..65535
/// </summary>
private static ushort ToRaw(double value, ThingModelPointEntity point)
{
double raw = (value - point.Offset) / point.Scale;
if (point.DataType == ThingDataTypeEnum.Int16)
{
if (raw <= short.MinValue) return unchecked((ushort)short.MinValue);
if (raw >= short.MaxValue) return (ushort)short.MaxValue;
return unchecked((ushort)(short)Math.Round(raw));
}
if (raw <= 0) return 0;
if (raw >= 65535) return 65535;
return (ushort)Math.Round(raw);
}
/// <summary>
/// 工程值 → 32 位双寄存器字数组(raw32 = (value - Offset) / Scale
/// Int32 按有符号整数编码,Float 按 IEEE754 编码;字序按 Modbus 常规高字在前(AB CD
/// </summary>
private static ushort[] ToWords(double value, ThingModelPointEntity point)
{
uint bits;
if (point.DataType == ThingDataTypeEnum.Float)
{
bits = BitConverter.SingleToUInt32Bits((float)((value - point.Offset) / point.Scale));
}
else
{
double raw = Math.Round((value - point.Offset) / point.Scale);
if (raw < int.MinValue) raw = int.MinValue;
if (raw > int.MaxValue) raw = int.MaxValue;
bits = unchecked((uint)(int)raw);
}
return new[] { (ushort)(bits >> 16), (ushort)(bits & 0xFFFF) };
}
/// <summary>
/// 写一条设备日志(设备独享日志,前端设备日志抽屉可见)
/// </summary>
private static async Task WriteLogAsync(IotDeviceEntity device, string level, string logType, string message)
{
var now = DateTime.Now;
await SqlSugarContext.DbContext.Insertable(new DeviceLogEntity
{
DeviceId = device.Id,
DeviceCode = device.Code,
Level = level,
LogType = logType,
Message = message,
LogTime = now,
CreateTime = now
}).ExecuteCommandAsync();
}
}
}
+250
View File
@@ -0,0 +1,250 @@
using Model;
using Model.Dto.Config;
using Model.Entity.Config;
using Model.Mapper;
using ORM;
using Service.Interface;
using Service.Interface.Config;
using SqlSugar;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
namespace Service.Implement.Config
{
/// <summary>
/// IOT设备管理 服务实现
/// </summary>
public class DeviceService : IDeviceService
{
private readonly IAuditRecorder _audit;
public DeviceService(IAuditRecorder audit)
{
_audit = audit;
}
/// <summary>
/// 分页查询设备列表(支持关键字搜索编号/名称/类型,可按所属产品筛选)
/// </summary>
public async Task<Result<List<IotDeviceDto>>> GetPagedAsync(int pageIndex, int pageSize, RefAsync<int> total, string? keyword, long? productId = null, long? gatewayId = null)
{
try
{
var list = await SqlSugarContext.DbContext.Queryable<IotDeviceEntity>()
.Where(x => x.IsDel == 0)
.WhereIF(!string.IsNullOrWhiteSpace(keyword),
x => x.Code.Contains(keyword!) || x.Name.Contains(keyword!) || x.DeviceType.Contains(keyword!))
.WhereIF(productId.HasValue && productId.Value > 0, x => x.ProductId == productId!.Value)
.WhereIF(gatewayId.HasValue && gatewayId.Value > 0, x => x.GatewayId == gatewayId!.Value)
.OrderBy(x => x.CreateTime, OrderByType.Desc)
.ToPageListAsync(pageIndex, pageSize, total);
var dtos = list.ToDtoList();
await FillProductNames(dtos);
await FillGatewayNames(dtos);
return Result<List<IotDeviceDto>>.Success(dtos);
}
catch (Exception ex)
{
return Result<List<IotDeviceDto>>.Error("查询设备列表失败", ex);
}
}
/// <summary>
/// 根据 Id 获取设备详情
/// </summary>
public async Task<Result<IotDeviceDto>> GetByIdAsync(long id)
{
try
{
var entity = await SqlSugarContext.DbContext.Queryable<IotDeviceEntity>()
.Where(x => x.Id == id && x.IsDel == 0)
.FirstAsync();
if (entity == null)
return Result<IotDeviceDto>.Error("设备不存在或已被删除");
var dto = entity.ToDto();
await FillProductNames(new List<IotDeviceDto> { dto });
await FillGatewayNames(new List<IotDeviceDto> { dto });
return Result<IotDeviceDto>.Success(dto);
}
catch (Exception ex)
{
return Result<IotDeviceDto>.Error("查询设备详情失败", ex);
}
}
/// <summary>
/// 新增设备(校验编号唯一性)
/// </summary>
public async Task<Result> AddAsync(IotDeviceDto dto)
{
if (dto == null || string.IsNullOrWhiteSpace(dto.Code))
return Result.Error("设备编号不能为空");
if (string.IsNullOrWhiteSpace(dto.Name))
return Result.Error("设备名称不能为空");
try
{
bool exists = await SqlSugarContext.DbContext.Queryable<IotDeviceEntity>()
.AnyAsync(x => x.Code == dto.Code && x.IsDel == 0);
if (exists)
return Result.Error($"设备编号【{dto.Code}】已存在");
var entity = dto.ToEntity();
entity.CreateTime = DateTime.Now;
// 初始离线
entity.OnlineStatus = IotDeviceOnlineStatusEnum.Offline;
await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync();
await _audit.RecordAsync("Create", "IotDevice", entity.Id, entity.Id,
null, JsonSerializer.Serialize(new { entity.Code, entity.Name, entity.DeviceType, entity.GatewayId, entity.SlaveId }),
description: $"新增设备 {entity.Code}{entity.Name}");
return Result.Success();
}
catch (Exception ex)
{
return Result.Error("新增设备失败", ex);
}
}
/// <summary>
/// 修改设备(在线状态等运行字段不允许被前端覆盖)
/// </summary>
public async Task<Result> UpdateAsync(IotDeviceDto dto)
{
var entity = dto?.ToEntity();
if (entity == null || entity.Id <= 0)
return Result.Error("设备Id无效");
try
{
// 编号变更时校验唯一性
bool exists = await SqlSugarContext.DbContext.Queryable<IotDeviceEntity>()
.AnyAsync(x => x.Code == entity.Code && x.IsDel == 0 && x.Id != entity.Id);
if (exists)
return Result.Error($"设备编号【{entity.Code}】已存在");
// 操作前值(审计):先查旧记录
var oldEntity = await SqlSugarContext.DbContext.Queryable<IotDeviceEntity>()
.Where(x => x.Id == entity.Id).FirstAsync();
await SqlSugarContext.DbContext.Updateable(entity)
.IgnoreColumns(x => new { x.CreateTime, x.IsDel, x.OnlineStatus, x.LastCollectTime, x.LastError })
.ExecuteCommandAsync();
await _audit.RecordAsync("Update", "IotDevice", entity.Id, entity.Id,
oldValue: oldEntity == null ? null : JsonSerializer.Serialize(new { oldEntity.Code, oldEntity.Name, oldEntity.DeviceType, oldEntity.GatewayId, oldEntity.SlaveId }),
newValue: JsonSerializer.Serialize(new { entity.Code, entity.Name, entity.DeviceType, entity.GatewayId, entity.SlaveId }),
description: $"修改设备 {entity.Code}{entity.Name}");
return Result.Success();
}
catch (Exception ex)
{
return Result.Error("修改设备失败", ex);
}
}
/// <summary>
/// 删除设备(软删除:IsDel 置 1)
/// </summary>
public async Task<Result> DeleteAsync(long id)
{
if (id <= 0)
return Result.Error("设备Id无效");
try
{
var oldEntity = await SqlSugarContext.DbContext.Queryable<IotDeviceEntity>()
.Where(x => x.Id == id && x.IsDel == 0).FirstAsync();
await SqlSugarContext.DbContext.Updateable<IotDeviceEntity>()
.SetColumns(x => x.IsDel == 1)
.Where(x => x.Id == id)
.ExecuteCommandAsync();
await _audit.RecordAsync("Delete", "IotDevice", id, id,
oldValue: oldEntity == null ? null : JsonSerializer.Serialize(new { oldEntity.Code, oldEntity.Name }),
description: $"删除设备 {oldEntity?.Code}{oldEntity?.Name}");
return Result.Success();
}
catch (Exception ex)
{
return Result.Error("删除设备失败", ex);
}
}
/// <summary>
/// 分页查询指定设备的日志(设备独享日志)
/// </summary>
public async Task<Result<List<DeviceLogDto>>> GetDeviceLogsAsync(long deviceId, int pageIndex, int pageSize, RefAsync<int> total)
{
try
{
var list = await SqlSugarContext.DbContext.Queryable<DeviceLogEntity>()
.Where(x => x.DeviceId == deviceId && x.IsDel == 0)
.OrderBy(x => x.LogTime, OrderByType.Desc)
.ToPageListAsync(pageIndex, pageSize, total);
return Result<List<DeviceLogDto>>.Success(list.ToDtoList());
}
catch (Exception ex)
{
return Result<List<DeviceLogDto>>.Error("查询设备日志失败", ex);
}
}
/// <summary>
/// 给设备 DTO 填充所属网关名称(GatewayName 展示用)
/// </summary>
private async Task FillGatewayNames(List<IotDeviceDto> dtos)
{
if (dtos == null || dtos.Count == 0)
return;
var ids = dtos.Where(x => long.TryParse(x.GatewayId, out var gid) && gid > 0)
.Select(x => long.Parse(x.GatewayId!)).Distinct().ToList();
if (ids.Count == 0)
return;
var gateways = await SqlSugarContext.DbContext.Queryable<GatewayEntity>()
.Where(x => ids.Contains(x.Id) && x.IsDel == 0)
.ToListAsync();
var map = gateways.ToDictionary(g => g.Id, g => $"{g.Code}({g.Name})");
foreach (var d in dtos)
{
if (long.TryParse(d.GatewayId, out var gid) && map.TryGetValue(gid, out var name))
d.GatewayName = name;
}
}
/// <summary>
/// 给设备 DTO 填充所属产品的型号/名称(ProductName 展示用)
/// </summary>
private async Task FillProductNames(List<IotDeviceDto> dtos)
{
if (dtos == null || dtos.Count == 0)
return;
var ids = dtos.Where(x => long.TryParse(x.ProductId, out var pid) && pid > 0)
.Select(x => long.Parse(x.ProductId!)).Distinct().ToList();
if (ids.Count == 0)
return;
var products = await SqlSugarContext.DbContext.Queryable<ProductEntity>()
.Where(x => ids.Contains(x.Id) && x.IsDel == 0)
.ToListAsync();
var map = products.ToDictionary(p => p.Id, p => p.Name ?? p.Model);
foreach (var d in dtos)
{
if (long.TryParse(d.ProductId, out var pid) && map.TryGetValue(pid, out var name))
d.ProductName = name;
}
}
}
}
+253 -3
View File
@@ -1,12 +1,262 @@
using Service.Interface;
using Model;
using Model.Dto.Config;
using Model.Entity.Config;
using Model.Mapper;
using ORM;
using Service.Interface.Config;
using SqlSugar;
using System.IO.Ports;
using System.Net;
using System.Net.Sockets;
namespace Service.Implement
namespace Service.Implement.Config
{
/// <summary>
/// 网关管理 服务实现
/// </summary>
public class GatewayService : IGatewayService
{
// TODO: 实现 网关管理 相关方法
public async Task<Result<List<GatewayDto>>> GetPagedAsync(int pageIndex, int pageSize, RefAsync<int> total, string? keyword = null, int? protocolType = null)
{
try
{
var list = await SqlSugarContext.DbContext.Queryable<GatewayEntity>()
.Where(x => x.IsDel == 0)
.WhereIF(!string.IsNullOrWhiteSpace(keyword), x => x.Code.Contains(keyword!) || x.Name.Contains(keyword!))
.WhereIF(protocolType.HasValue, x => x.ProtocolType == (IotDeviceProtocolEnum)protocolType!.Value)
.OrderBy(x => x.CreateTime, OrderByType.Desc)
.ToPageListAsync(pageIndex, pageSize, total);
return Result<List<GatewayDto>>.Success(list.ToDtoList());
}
catch (Exception ex)
{
return Result<List<GatewayDto>>.Error("查询网关列表失败", ex);
}
}
public async Task<Result<GatewayDto>> GetByIdAsync(long id)
{
try
{
var entity = await SqlSugarContext.DbContext.Queryable<GatewayEntity>()
.Where(x => x.Id == id && x.IsDel == 0).FirstAsync();
if (entity == null) return Result<GatewayDto>.Error("网关不存在");
return Result<GatewayDto>.Success(entity.ToDto());
}
catch (Exception ex)
{
return Result<GatewayDto>.Error("查询网关详情失败", ex);
}
}
public async Task<Result<GatewayDto>> AddAsync(GatewayDto dto)
{
try
{
// 编码唯一校验
var exists = await SqlSugarContext.DbContext.Queryable<GatewayEntity>()
.Where(x => x.Code == dto.Code && x.IsDel == 0).AnyAsync();
if (exists) return Result<GatewayDto>.Error($"网关编码「{dto.Code}」已存在");
var entity = dto.ToEntity();
entity.Id = 0; // 雪花ID自动生成
var id = await SqlSugarContext.DbContext.Insertable(entity).ExecuteReturnSnowflakeIdAsync();
entity.Id = id;
return Result<GatewayDto>.Success(entity.ToDto());
}
catch (Exception ex)
{
return Result<GatewayDto>.Error("新增网关失败", ex);
}
}
public async Task<Result<GatewayDto>> UpdateAsync(GatewayDto dto)
{
try
{
if (!long.TryParse(dto.Id, out var id) || id <= 0)
return Result<GatewayDto>.Error("网关Id无效");
var entity = await SqlSugarContext.DbContext.Queryable<GatewayEntity>()
.Where(x => x.Id == id && x.IsDel == 0).FirstAsync();
if (entity == null) return Result<GatewayDto>.Error("网关不存在");
// 编码唯一校验(排除自身)
var codeExists = await SqlSugarContext.DbContext.Queryable<GatewayEntity>()
.Where(x => x.Code == dto.Code && x.Id != id && x.IsDel == 0).AnyAsync();
if (codeExists) return Result<GatewayDto>.Error($"网关编码「{dto.Code}」已存在");
var newEntity = dto.ToEntity();
newEntity.Id = id;
await SqlSugarContext.DbContext.Updateable(newEntity)
.IgnoreColumns(x => new { x.CreateTime, x.IsDel })
.ExecuteCommandAsync();
return Result<GatewayDto>.Success(newEntity.ToDto());
}
catch (Exception ex)
{
return Result<GatewayDto>.Error("修改网关失败", ex);
}
}
public async Task<Result> DeleteAsync(long id)
{
try
{
var entity = await SqlSugarContext.DbContext.Queryable<GatewayEntity>()
.Where(x => x.Id == id && x.IsDel == 0).FirstAsync();
if (entity == null) return Result.Error("网关不存在");
// 软删除网关
await SqlSugarContext.DbContext.Updateable<GatewayEntity>()
.SetColumns(x => x.IsDel == 1)
.Where(x => x.Id == id).ExecuteCommandAsync();
// 该网关下设备的 GatewayId 置 0(不级联删除设备)
await SqlSugarContext.DbContext.Updateable<IotDeviceEntity>()
.SetColumns(x => x.GatewayId == 0)
.Where(x => x.GatewayId == id).ExecuteCommandAsync();
return Result.Success();
}
catch (Exception ex)
{
return Result.Error("删除网关失败", ex);
}
}
/// <summary>
/// 测试网关连接:TCP 尝试 TcpClient.ConnectAsync 3s 超时,串口尝试 SerialPort.Open
/// 成功则 OnlineStatus=Online+更新 LastConnectedTime,失败则 OnlineStatus=Fault+记录 LastError
/// </summary>
public async Task<Result<GatewayTestResultDto>> TestConnectionAsync(long id)
{
try
{
var entity = await SqlSugarContext.DbContext.Queryable<GatewayEntity>()
.Where(x => x.Id == id && x.IsDel == 0).FirstAsync();
if (entity == null) return Result<GatewayTestResultDto>.Error("网关不存在");
bool success = false;
string message = "";
var now = DateTime.Now;
switch (entity.ProtocolType)
{
case IotDeviceProtocolEnum.ModbusTcp:
case IotDeviceProtocolEnum.Tcp:
case IotDeviceProtocolEnum.S7:
// TCP 类:尝试连接 IP:Port
if (string.IsNullOrWhiteSpace(entity.Host) || !entity.Port.HasValue)
{
message = "未配置 IP 地址或端口号";
}
else
{
(success, message) = await TestTcpAsync(entity.Host, entity.Port!.Value, 3000);
}
break;
case IotDeviceProtocolEnum.ModbusRtu:
case IotDeviceProtocolEnum.Serial:
// 串口类:尝试打开串口
if (string.IsNullOrWhiteSpace(entity.ComPort))
{
message = "未配置串口号";
}
else
{
(success, message) = TestSerial(entity.ComPort, entity.BaudRate ?? 9600);
}
break;
default:
message = $"暂不支持 {entity.ProtocolType} 协议的连接测试";
break;
}
// 更新网关状态
await SqlSugarContext.DbContext.Updateable<GatewayEntity>()
.SetColumns(x => new GatewayEntity
{
OnlineStatus = success ? (byte)1 : (byte)3,
LastConnectedTime = success ? now : x.LastConnectedTime,
LastError = success ? null : message
})
.Where(x => x.Id == id).ExecuteCommandAsync();
return Result<GatewayTestResultDto>.Success(new GatewayTestResultDto
{
Success = success,
Message = success ? $"连接成功({entity.Host}:{entity.Port ?? 0}" : message
});
}
catch (Exception ex)
{
return Result<GatewayTestResultDto>.Error("测试连接失败", ex);
}
}
public async Task<Result<List<GatewayOptionDto>>> GetOptionsAsync()
{
try
{
var list = await SqlSugarContext.DbContext.Queryable<GatewayEntity>()
.Where(x => x.IsDel == 0 && x.IsEnabled)
.OrderBy(x => x.Code)
.ToListAsync();
return Result<List<GatewayOptionDto>>.Success(list.ToOptionDtoList());
}
catch (Exception ex)
{
return Result<List<GatewayOptionDto>>.Error("查询网关选项失败", ex);
}
}
#region
private static async Task<(bool success, string message)> TestTcpAsync(string host, int port, int timeoutMs)
{
try
{
using var client = new TcpClient();
var cts = new System.Threading.CancellationTokenSource(timeoutMs);
await client.ConnectAsync(host, port, cts.Token);
return (true, "");
}
catch (OperationCanceledException)
{
return (false, $"连接超时({timeoutMs / 1000}s):{host}:{port}");
}
catch (SocketException ex)
{
return (false, $"连接失败:{host}:{port}{ex.Message}");
}
catch (Exception ex)
{
return (false, $"连接异常:{ex.Message}");
}
}
private static (bool success, string message) TestSerial(string comPort, int baudRate)
{
try
{
using var sp = new SerialPort(comPort, baudRate);
sp.Open();
sp.Close();
return (true, "");
}
catch (UnauthorizedAccessException)
{
return (false, $"串口 {comPort} 被占用");
}
catch (Exception ex)
{
return (false, $"打开串口 {comPort} 失败:{ex.Message}");
}
}
#endregion
}
}
@@ -1,12 +0,0 @@
using Service.Interface;
namespace Service.Implement
{
/// <summary>
/// IOT设备管理 服务实现
/// </summary>
public class IotDeviceService : IIotDeviceService
{
// TODO: 实现 IOT设备管理 相关方法
}
}
+247 -4
View File
@@ -1,12 +1,255 @@
using Service.Interface;
using Model;
using Model.Dto.Config;
using Model.Entity.Config;
using Model.Mapper;
using ORM;
using Service.Interface.Config;
using SqlSugar;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Service.Implement
namespace Service.Implement.Config
{
/// <summary>
/// 产品管理 服务实现
/// 产品管理(含产品分类)服务实现
/// </summary>
public class ProductService : IProductService
{
// TODO: 实现 产品管理 相关方法
// ===================== 产品分类 =====================
public async Task<Result<List<ProductCategoryDto>>> GetCategoriesAsync()
{
try
{
var list = await SqlSugarContext.DbContext.Queryable<ProductCategoryEntity>()
.Where(x => x.IsDel == 0)
.OrderBy(x => x.Sort)
.ToListAsync();
return Result<List<ProductCategoryDto>>.Success(list.ToDtoList());
}
catch (Exception ex)
{
return Result<List<ProductCategoryDto>>.Error("查询产品分类失败", ex);
}
}
public async Task<Result> AddCategoryAsync(ProductCategoryDto dto)
{
if (dto == null || string.IsNullOrWhiteSpace(dto.Code))
return Result.Error("分类编码不能为空");
try
{
bool exists = await SqlSugarContext.DbContext.Queryable<ProductCategoryEntity>()
.AnyAsync(x => x.Code == dto.Code && x.IsDel == 0);
if (exists)
return Result.Error($"分类编码【{dto.Code}】已存在");
var entity = dto.ToEntity();
entity.CreateTime = DateTime.Now;
await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync();
return Result.Success();
}
catch (Exception ex)
{
return Result.Error("新增产品分类失败", ex);
}
}
public async Task<Result> UpdateCategoryAsync(ProductCategoryDto dto)
{
var entity = dto?.ToEntity();
if (entity == null || entity.Id <= 0)
return Result.Error("分类Id无效");
try
{
bool exists = await SqlSugarContext.DbContext.Queryable<ProductCategoryEntity>()
.AnyAsync(x => x.Code == entity.Code && x.IsDel == 0 && x.Id != entity.Id);
if (exists)
return Result.Error($"分类编码【{entity.Code}】已存在");
await SqlSugarContext.DbContext.Updateable(entity)
.IgnoreColumns(x => new { x.CreateTime, x.IsDel })
.ExecuteCommandAsync();
return Result.Success();
}
catch (Exception ex)
{
return Result.Error("修改产品分类失败", ex);
}
}
public async Task<Result> DeleteCategoryAsync(long id)
{
if (id <= 0)
return Result.Error("分类Id无效");
try
{
await SqlSugarContext.DbContext.Updateable<ProductCategoryEntity>()
.SetColumns(x => x.IsDel == 1)
.Where(x => x.Id == id)
.ExecuteCommandAsync();
return Result.Success();
}
catch (Exception ex)
{
return Result.Error("删除产品分类失败", ex);
}
}
// ===================== 产品 =====================
public async Task<Result<List<ProductDto>>> GetPagedAsync(int pageIndex, int pageSize, RefAsync<int> total, string? keyword, long categoryId)
{
try
{
var list = await SqlSugarContext.DbContext.Queryable<ProductEntity>()
.Where(x => x.IsDel == 0)
.WhereIF(!string.IsNullOrWhiteSpace(keyword),
x => x.Model.Contains(keyword!) || (x.Name != null && x.Name.Contains(keyword!)))
.WhereIF(categoryId > 0, x => x.CategoryId == categoryId)
.OrderBy(x => x.CreateTime, OrderByType.Desc)
.ToPageListAsync(pageIndex, pageSize, total);
var dtos = list.ToDtoList();
await FillCategoryNames(dtos);
return Result<List<ProductDto>>.Success(dtos);
}
catch (Exception ex)
{
return Result<List<ProductDto>>.Error("查询产品列表失败", ex);
}
}
public async Task<Result<List<ProductOptionDto>>> GetOptionsAsync()
{
try
{
var list = await SqlSugarContext.DbContext.Queryable<ProductEntity>()
.Where(x => x.IsDel == 0 && x.IsEnabled)
.OrderBy(x => x.Model)
.ToListAsync();
return Result<List<ProductOptionDto>>.Success(list.ToOptionDtoList());
}
catch (Exception ex)
{
return Result<List<ProductOptionDto>>.Error("查询产品选项失败", ex);
}
}
public async Task<Result<ProductDto>> GetByIdAsync(long id)
{
try
{
var entity = await SqlSugarContext.DbContext.Queryable<ProductEntity>()
.Where(x => x.Id == id && x.IsDel == 0)
.FirstAsync();
if (entity == null)
return Result<ProductDto>.Error("产品不存在或已被删除");
var dto = entity.ToDto();
await FillCategoryNames(new List<ProductDto> { dto });
return Result<ProductDto>.Success(dto);
}
catch (Exception ex)
{
return Result<ProductDto>.Error("查询产品详情失败", ex);
}
}
public async Task<Result> AddAsync(ProductDto dto)
{
if (dto == null || string.IsNullOrWhiteSpace(dto.Model))
return Result.Error("产品型号不能为空");
try
{
bool exists = await SqlSugarContext.DbContext.Queryable<ProductEntity>()
.AnyAsync(x => x.Model == dto.Model && x.IsDel == 0);
if (exists)
return Result.Error($"产品型号【{dto.Model}】已存在");
var entity = dto.ToEntity();
entity.CreateTime = DateTime.Now;
await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync();
return Result.Success();
}
catch (Exception ex)
{
return Result.Error("新增产品失败", ex);
}
}
public async Task<Result> UpdateAsync(ProductDto dto)
{
var entity = dto?.ToEntity();
if (entity == null || entity.Id <= 0)
return Result.Error("产品Id无效");
try
{
bool exists = await SqlSugarContext.DbContext.Queryable<ProductEntity>()
.AnyAsync(x => x.Model == entity.Model && x.IsDel == 0 && x.Id != entity.Id);
if (exists)
return Result.Error($"产品型号【{entity.Model}】已存在");
await SqlSugarContext.DbContext.Updateable(entity)
.IgnoreColumns(x => new { x.CreateTime, x.IsDel })
.ExecuteCommandAsync();
return Result.Success();
}
catch (Exception ex)
{
return Result.Error("修改产品失败", ex);
}
}
public async Task<Result> DeleteAsync(long id)
{
if (id <= 0)
return Result.Error("产品Id无效");
try
{
await SqlSugarContext.DbContext.Updateable<ProductEntity>()
.SetColumns(x => x.IsDel == 1)
.Where(x => x.Id == id)
.ExecuteCommandAsync();
return Result.Success();
}
catch (Exception ex)
{
return Result.Error("删除产品失败", ex);
}
}
/// <summary>
/// 给产品 DTO 填充分类名称(供列表/详情展示)
/// </summary>
private async Task FillCategoryNames(List<ProductDto> dtos)
{
if (dtos == null || dtos.Count == 0)
return;
var ids = dtos.Where(x => x.CategoryId != null && long.TryParse(x.CategoryId, out _))
.Select(x => long.Parse(x.CategoryId!)).Distinct().ToList();
if (ids.Count == 0)
return;
var categories = await SqlSugarContext.DbContext.Queryable<ProductCategoryEntity>()
.Where(x => ids.Contains(x.Id) && x.IsDel == 0)
.ToListAsync();
var map = categories.ToDictionary(c => c.Id, c => c.Name);
foreach (var d in dtos)
{
if (long.TryParse(d.CategoryId, out var cid) && map.TryGetValue(cid, out var name))
d.CategoryName = name;
}
}
}
}
@@ -0,0 +1,126 @@
using Model;
using Model.Dto.Config;
using Model.Entity.Config;
using Model.Mapper;
using ORM;
using Service.Interface.Config;
using SqlSugar;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Service.Implement.Config
{
/// <summary>
/// 物模型点 服务实现(产品/设备通用,按 OwnerType+OwnerId 归属)
/// </summary>
public class ThingPointService : IThingPointService
{
public async Task<Result<List<ThingModelPointDto>>> GetListAsync(int ownerType, long ownerId, string? keyword)
{
try
{
var list = await SqlSugarContext.DbContext.Queryable<ThingModelPointEntity>()
.Where(x => x.IsDel == 0)
.Where(x => x.OwnerType == (ThingOwnerTypeEnum)ownerType && x.OwnerId == ownerId)
.WhereIF(!string.IsNullOrWhiteSpace(keyword),
x => x.Code.Contains(keyword!) || (x.Name != null && x.Name.Contains(keyword!)))
.OrderBy(x => x.Sort)
.ToListAsync();
return Result<List<ThingModelPointDto>>.Success(list.ToDtoList());
}
catch (Exception ex)
{
return Result<List<ThingModelPointDto>>.Error("查询物模型点失败", ex);
}
}
public async Task<Result<ThingModelPointDto>> GetByIdAsync(long id)
{
try
{
var entity = await SqlSugarContext.DbContext.Queryable<ThingModelPointEntity>()
.Where(x => x.Id == id && x.IsDel == 0)
.FirstAsync();
if (entity == null)
return Result<ThingModelPointDto>.Error("物模型点不存在或已被删除");
return Result<ThingModelPointDto>.Success(entity.ToDto());
}
catch (Exception ex)
{
return Result<ThingModelPointDto>.Error("查询物模型点失败", ex);
}
}
public async Task<Result> AddAsync(ThingModelPointDto dto)
{
if (dto == null || string.IsNullOrWhiteSpace(dto.Code))
return Result.Error("点编码不能为空");
if (dto.OwnerId == null || !long.TryParse(dto.OwnerId, out var ownerId) || ownerId <= 0)
return Result.Error("归属对象Id无效");
try
{
bool exists = await SqlSugarContext.DbContext.Queryable<ThingModelPointEntity>()
.AnyAsync(x => x.OwnerType == dto.OwnerType && x.OwnerId == ownerId
&& x.Code == dto.Code && x.IsDel == 0);
if (exists)
return Result.Error($"点编码【{dto.Code}】在该归属下已存在");
var entity = dto.ToEntity();
entity.CreateTime = DateTime.Now;
await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync();
return Result.Success();
}
catch (Exception ex)
{
return Result.Error("新增物模型点失败", ex);
}
}
public async Task<Result> UpdateAsync(ThingModelPointDto dto)
{
var entity = dto?.ToEntity();
if (entity == null || entity.Id <= 0)
return Result.Error("物模型点Id无效");
try
{
bool exists = await SqlSugarContext.DbContext.Queryable<ThingModelPointEntity>()
.AnyAsync(x => x.OwnerType == entity.OwnerType && x.OwnerId == entity.OwnerId
&& x.Code == entity.Code && x.IsDel == 0 && x.Id != entity.Id);
if (exists)
return Result.Error($"点编码【{entity.Code}】在该归属下已存在");
await SqlSugarContext.DbContext.Updateable(entity)
.IgnoreColumns(x => new { x.CreateTime, x.IsDel, x.OwnerType, x.OwnerId })
.ExecuteCommandAsync();
return Result.Success();
}
catch (Exception ex)
{
return Result.Error("修改物模型点失败", ex);
}
}
public async Task<Result> DeleteAsync(long id)
{
if (id <= 0)
return Result.Error("物模型点Id无效");
try
{
await SqlSugarContext.DbContext.Updateable<ThingModelPointEntity>()
.SetColumns(x => x.IsDel == 1)
.Where(x => x.Id == id)
.ExecuteCommandAsync();
return Result.Success();
}
catch (Exception ex)
{
return Result.Error("删除物模型点失败", ex);
}
}
}
}
@@ -0,0 +1,115 @@
using Model;
using Model.Dto.Inspection;
using Model.Entity.Inspection;
using Model.Mapper;
using ORM;
using Service.Interface;
using SqlSugar;
using System;
using System.Threading.Tasks;
namespace Service.Implement
{
/// <summary>
/// 告警中心服务实现(全系统统一告警上报入口)
/// 职责:默认内容生成 → 去重合并(防告警风暴)→ 落库 → 规则触发计数 → 通知队列入队
/// </summary>
public class AlertCenterService : IAlertCenterService
{
/// <summary>
/// 合并窗口(分钟):窗口内同设备+同测点+同类型的活跃告警不新建记录,只累加触发次数。
/// 后续可迁移到系统参数配置模块动态调整
/// </summary>
private const int MergeWindowMinutes = 5;
/// <summary>
/// 上报一条告警
/// </summary>
public async Task<Result<string>> RaiseAsync(AlertRaiseDto dto)
{
if (dto == null)
{
return Result<string>.Error("告警上报参数为空");
}
if (string.IsNullOrWhiteSpace(dto.DeviceCode) && string.IsNullOrWhiteSpace(dto.Content))
{
return Result<string>.Error("设备编号与告警内容不能同时为空");
}
try
{
var now = DateTime.Now;
// ===== 去重合并:窗口内相同 设备+测点+类型 的活跃告警(未确认/已确认)只累加不新建 =====
var active = await SqlSugarContext.DbContext.Queryable<AlertMessageEntity>()
.Where(x => x.IsDel == 0)
.Where(x => x.DeviceCode == dto.DeviceCode && x.Metric == dto.Metric && x.AlertType == dto.AlertType)
.Where(x => x.AlertStatus == AlertStatusEnum.Unacknowledged || x.AlertStatus == AlertStatusEnum.Acknowledged)
.Where(x => x.LastAlertTime >= now.AddMinutes(-MergeWindowMinutes))
.FirstAsync();
if (active != null)
{
// 累加触发次数、刷新最近告警时间与当前值;级别取更高者(告警升级)
var mergedLevel = active.AlertLevel > dto.AlertLevel ? active.AlertLevel : dto.AlertLevel;
await SqlSugarContext.DbContext.Updateable<AlertMessageEntity>()
.SetColumns(x => new AlertMessageEntity
{
TriggerCount = x.TriggerCount + 1,
LastAlertTime = now,
CurrentValue = dto.CurrentValue ?? x.CurrentValue,
AlertLevel = mergedLevel
})
.Where(x => x.Id == active.Id)
.ExecuteCommandAsync();
// 合并告警也入队(是否真正推送由 Worker 按通知规则静默期决定)
AlertNotifyBus.Publish(active.Id, false);
return Result<string>.Success(active.Id.ToString());
}
// ===== 新告警:生成默认内容 → 落库 → 规则计数 → 入队 =====
var entity = dto.ToEntity();
if (string.IsNullOrWhiteSpace(entity.Content))
{
entity.Content = BuildDefaultContent(entity);
}
entity.CreateTime = now;
entity.FirstAlertTime = now;
entity.LastAlertTime = now;
entity.TriggerCount = 1;
entity.AlertStatus = AlertStatusEnum.Unacknowledged;
await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync();
// 来源为规则引擎时,回写规则触发统计(与黄的规则引擎模块联动)
if (entity.RuleId > 0)
{
await SqlSugarContext.DbContext.Updateable<AlertRuleEntity>()
.SetColumns(x => new AlertRuleEntity { TriggerCount = x.TriggerCount + 1, LastAlarmTime = now })
.Where(x => x.Id == entity.RuleId)
.ExecuteCommandAsync();
}
AlertNotifyBus.Publish(entity.Id, true);
return Result<string>.Success(entity.Id.ToString());
}
catch (Exception ex)
{
return Result<string>.Error("告警上报失败", ex);
}
}
/// <summary>
/// 按内置模板生成默认告警内容(后续可由通知模板模块接管,见 TemplateCode 预留字段)
/// </summary>
private static string BuildDefaultContent(AlertMessageEntity e)
{
string device = string.IsNullOrWhiteSpace(e.DeviceName) ? e.DeviceCode ?? "未知设备" : $"{e.DeviceName}({e.DeviceCode})";
string metric = string.IsNullOrWhiteSpace(e.Metric) ? "" : $" 测点[{e.Metric}]";
string value = e.CurrentValue.HasValue ? $" 当前值 {e.CurrentValue.Value}" : "";
string threshold = e.ThresholdValue.HasValue ? $",阈值 {e.ThresholdValue.Value}" : "";
return $"{device}{metric} 触发告警:{value}{threshold}";
}
}
}
@@ -1,12 +1,412 @@
using Model;
using Model.Dto.Inspection;
using Model.Entity.Inspection;
using Model.Mapper;
using ORM;
using Service.Interface;
using SqlSugar;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
namespace Service.Implement
{
/// <summary>
/// 消息告警 服务实现
/// 消息告警 服务实现(告警列表/闭环操作/统计分析)
/// </summary>
public class AlertMessageService : IAlertMessageService
{
// TODO: 实现 消息告警 相关方法
#region
/// <summary>
/// 分页查询告警(支持级别/状态/类型/来源/设备/关键字/时间范围筛选)
/// </summary>
public async Task<Result<List<AlertMessageDto>>> GetPagedAsync(int pageIndex, int pageSize, RefAsync<int> total,
AlertLevelEnum? level, AlertStatusEnum? status, AlertTypeEnum? type, AlertSourceEnum? source,
string? deviceCode, string? keyword, DateTime? startDate, DateTime? endDate)
{
try
{
var list = await SqlSugarContext.DbContext.Queryable<AlertMessageEntity>()
.Where(x => x.IsDel == 0)
.WhereIF(level.HasValue, x => x.AlertLevel == level!.Value)
.WhereIF(status.HasValue, x => x.AlertStatus == status!.Value)
.WhereIF(type.HasValue, x => x.AlertType == type!.Value)
.WhereIF(source.HasValue, x => x.Source == source!.Value)
.WhereIF(!string.IsNullOrWhiteSpace(deviceCode), x => x.DeviceCode == deviceCode)
.WhereIF(!string.IsNullOrWhiteSpace(keyword),
x => x.DeviceCode!.Contains(keyword!) || x.DeviceName!.Contains(keyword!) || x.Content!.Contains(keyword!))
.WhereIF(startDate.HasValue, x => x.FirstAlertTime >= startDate!.Value)
.WhereIF(endDate.HasValue, x => x.FirstAlertTime <= endDate!.Value)
.OrderBy(x => x.LastAlertTime, OrderByType.Desc)
.ToPageListAsync(pageIndex, pageSize, total);
// total 保持为总记录数(前端分页组件需要)
return Result<List<AlertMessageDto>>.Success(list.ToDtoList());
}
catch (Exception ex)
{
return Result<List<AlertMessageDto>>.Error("分页查询告警失败", ex);
}
}
/// <summary>
/// 告警详情
/// </summary>
public async Task<Result<AlertMessageDto>> GetByIdAsync(long id)
{
try
{
var entity = await SqlSugarContext.DbContext.Queryable<AlertMessageEntity>()
.Where(x => x.Id == id && x.IsDel == 0)
.FirstAsync();
if (entity == null)
{
return Result<AlertMessageDto>.Error("告警不存在或已被删除");
}
return Result<AlertMessageDto>.Success(entity.ToDto());
}
catch (Exception ex)
{
return Result<AlertMessageDto>.Error("查询告警详情失败", ex);
}
}
#endregion
#region
/// <summary>
/// 确认告警
/// </summary>
public Task<Result<bool>> AcknowledgeAsync(long id, AlertOperateDto dto)
{
return OperateAsync(id, dto, AlertStatusEnum.Acknowledged, setAck: true, setHandle: false);
}
/// <summary>
/// 处理告警(未确认时同时补记确认信息,处理即视为已确认)
/// </summary>
public Task<Result<bool>> HandleAsync(long id, AlertOperateDto dto)
{
return OperateAsync(id, dto, AlertStatusEnum.Handled, setAck: true, setHandle: true);
}
/// <summary>
/// 关闭告警(处理完成后归档)
/// </summary>
public Task<Result<bool>> CloseAsync(long id, AlertOperateDto dto)
{
return OperateAsync(id, dto, AlertStatusEnum.Closed, setAck: false, setHandle: true);
}
/// <summary>
/// 忽略告警(误报等场景,记录忽略原因)
/// </summary>
public Task<Result<bool>> IgnoreAsync(long id, AlertOperateDto dto)
{
return OperateAsync(id, dto, AlertStatusEnum.Ignored, setAck: false, setHandle: true);
}
/// <summary>
/// 转工单(一期仅变更状态并记录 WorkOrderId,工单模块二期对接)
/// </summary>
public async Task<Result<bool>> ToWorkOrderAsync(long id, AlertOperateDto dto)
{
var result = await OperateAsync(id, dto, AlertStatusEnum.ToWorkOrder, setAck: false, setHandle: false);
if (result.IsSuccess && dto?.WorkOrderId > 0)
{
await SqlSugarContext.DbContext.Updateable<AlertMessageEntity>()
.SetColumns(x => x.WorkOrderId == dto.WorkOrderId)
.Where(x => x.Id == id)
.ExecuteCommandAsync();
}
return result;
}
/// <summary>
/// 闭环操作统一实现:校验终态 → 更新状态与操作人/时间
/// </summary>
private async Task<Result<bool>> OperateAsync(long id, AlertOperateDto dto, AlertStatusEnum target, bool setAck, bool setHandle)
{
if (id <= 0)
{
return Result<bool>.Error("告警 Id 无效");
}
try
{
var entity = await SqlSugarContext.DbContext.Queryable<AlertMessageEntity>()
.Where(x => x.Id == id && x.IsDel == 0)
.FirstAsync();
if (entity == null)
{
return Result<bool>.Error("告警不存在或已被删除");
}
// 已关闭/已忽略为终态,不允许再流转
if (entity.AlertStatus is AlertStatusEnum.Closed or AlertStatusEnum.Ignored)
{
return Result<bool>.Error($"告警已处于【{StatusLabel(entity.AlertStatus)}】终态,无法再变更");
}
var now = DateTime.Now;
string? op = dto?.Operator;
string? remark = dto?.Remark;
var updater = SqlSugarContext.DbContext.Updateable<AlertMessageEntity>()
.SetColumns(x => x.AlertStatus == target);
if (setAck && entity.AckTime == null)
{
updater = updater.SetColumns(x => new AlertMessageEntity { AckBy = op, AckTime = now });
}
if (setHandle)
{
updater = updater.SetColumns(x => new AlertMessageEntity { HandleBy = op, HandleTime = now, HandleRemark = remark });
}
var rows = await updater.Where(x => x.Id == id).ExecuteCommandAsync();
return Result<bool>.Success(rows > 0);
}
catch (Exception ex)
{
return Result<bool>.Error("告警操作失败", ex);
}
}
#endregion
#region
/// <summary>
/// 告警概览(列表页顶部卡片)
/// </summary>
public async Task<Result<AlertOverviewDto>> GetOverviewAsync()
{
try
{
var today = DateTime.Today;
var db = SqlSugarContext.DbContext;
var overview = new AlertOverviewDto
{
UnacknowledgedCount = await db.Queryable<AlertMessageEntity>()
.Where(x => x.IsDel == 0 && x.AlertStatus == AlertStatusEnum.Unacknowledged).CountAsync(),
TodayCount = await db.Queryable<AlertMessageEntity>()
.Where(x => x.IsDel == 0 && x.FirstAlertTime >= today).CountAsync(),
TodayUrgentCount = await db.Queryable<AlertMessageEntity>()
.Where(x => x.IsDel == 0 && x.FirstAlertTime >= today && x.AlertLevel == AlertLevelEnum.Urgent).CountAsync(),
WeekCount = await db.Queryable<AlertMessageEntity>()
.Where(x => x.IsDel == 0 && x.FirstAlertTime >= today.AddDays(-6)).CountAsync()
};
return Result<AlertOverviewDto>.Success(overview);
}
catch (Exception ex)
{
return Result<AlertOverviewDto>.Error("查询告警概览失败", ex);
}
}
/// <summary>
/// 趋势统计(granularityday/week/month;缺省时间范围为近30天)
/// </summary>
public async Task<Result<List<AlertTrendItemDto>>> GetTrendAsync(string granularity, DateTime? startDate, DateTime? endDate)
{
try
{
var (start, end) = NormalizeRange(startDate, endDate, 30);
// 告警经合并窗口去重后数据量可控,取必要列内存聚合(跨库兼容,避免各数据库日期函数差异)
var rows = await SqlSugarContext.DbContext.Queryable<AlertMessageEntity>()
.Where(x => x.IsDel == 0 && x.FirstAlertTime >= start && x.FirstAlertTime <= end)
.Select(x => new { x.FirstAlertTime, x.AlertLevel, x.AlertStatus })
.ToListAsync();
var grouped = rows.GroupBy(x => BucketOf(x.FirstAlertTime, granularity))
.OrderBy(g => g.Key, StringComparer.Ordinal)
.Select(g => new AlertTrendItemDto
{
Bucket = g.Key,
Total = g.Count(),
InfoCount = g.Count(x => x.AlertLevel == AlertLevelEnum.Info),
WarningCount = g.Count(x => x.AlertLevel == AlertLevelEnum.Warning),
UrgentCount = g.Count(x => x.AlertLevel == AlertLevelEnum.Urgent),
UnhandledCount = g.Count(x => x.AlertStatus is AlertStatusEnum.Unacknowledged or AlertStatusEnum.Acknowledged)
}).ToList();
return Result<List<AlertTrendItemDto>>.Success(grouped);
}
catch (Exception ex)
{
return Result<List<AlertTrendItemDto>>.Error("查询告警趋势失败", ex);
}
}
/// <summary>
/// 分布统计(dimensiondevice/type/level/source
/// </summary>
public async Task<Result<List<AlertDistributionItemDto>>> GetDistributionAsync(string dimension, DateTime? startDate, DateTime? endDate)
{
try
{
var (start, end) = NormalizeRange(startDate, endDate, 30);
var rows = await SqlSugarContext.DbContext.Queryable<AlertMessageEntity>()
.Where(x => x.IsDel == 0 && x.FirstAlertTime >= start && x.FirstAlertTime <= end)
.Select(x => new { x.DeviceCode, x.DeviceName, x.AlertType, x.AlertLevel, x.Source })
.ToListAsync();
List<AlertDistributionItemDto> result = (dimension ?? "level").ToLower() switch
{
"device" => rows.GroupBy(x => string.IsNullOrWhiteSpace(x.DeviceName) ? x.DeviceCode ?? "未知设备" : $"{x.DeviceName}({x.DeviceCode})")
.Select(g => new AlertDistributionItemDto { Name = g.Key, Count = g.Count() })
.OrderByDescending(x => x.Count).Take(20).ToList(),
"type" => rows.GroupBy(x => TypeLabel(x.AlertType))
.Select(g => new AlertDistributionItemDto { Name = g.Key, Count = g.Count() })
.OrderByDescending(x => x.Count).ToList(),
"source" => rows.GroupBy(x => SourceLabel(x.Source))
.Select(g => new AlertDistributionItemDto { Name = g.Key, Count = g.Count() })
.OrderByDescending(x => x.Count).ToList(),
_ => rows.GroupBy(x => AlertLevelMap.ToLabel(x.AlertLevel))
.Select(g => new AlertDistributionItemDto { Name = g.Key, Count = g.Count() })
.OrderByDescending(x => x.Count).ToList()
};
return Result<List<AlertDistributionItemDto>>.Success(result);
}
catch (Exception ex)
{
return Result<List<AlertDistributionItemDto>>.Error("查询告警分布失败", ex);
}
}
/// <summary>
/// 高频告警 TOP 排名(按设备聚合,次数含合并累计 TriggerCount
/// </summary>
public async Task<Result<List<AlertTopItemDto>>> GetTopAsync(int topN, DateTime? startDate, DateTime? endDate)
{
try
{
if (topN <= 0 || topN > 100) topN = 10;
var (start, end) = NormalizeRange(startDate, endDate, 30);
var rows = await SqlSugarContext.DbContext.Queryable<AlertMessageEntity>()
.Where(x => x.IsDel == 0 && x.FirstAlertTime >= start && x.FirstAlertTime <= end)
.Select(x => new { x.DeviceCode, x.DeviceName, x.TriggerCount, x.LastAlertTime })
.ToListAsync();
var top = rows.GroupBy(x => x.DeviceCode ?? "未知设备")
.Select(g => new AlertTopItemDto
{
DeviceCode = g.Key,
DeviceName = g.First().DeviceName,
Count = g.Sum(x => x.TriggerCount),
LastAlertTime = g.Max(x => x.LastAlertTime)
})
.OrderByDescending(x => x.Count)
.Take(topN)
.ToList();
return Result<List<AlertTopItemDto>>.Success(top);
}
catch (Exception ex)
{
return Result<List<AlertTopItemDto>>.Error("查询高频告警失败", ex);
}
}
/// <summary>
/// 处理及时率统计(平均确认/处理时长;及时率 = 30分钟内确认的占比)
/// </summary>
public async Task<Result<AlertTimelinessDto>> GetTimelinessAsync(DateTime? startDate, DateTime? endDate)
{
try
{
var (start, end) = NormalizeRange(startDate, endDate, 30);
var rows = await SqlSugarContext.DbContext.Queryable<AlertMessageEntity>()
.Where(x => x.IsDel == 0 && x.FirstAlertTime >= start && x.FirstAlertTime <= end)
.Select(x => new { x.FirstAlertTime, x.AckTime, x.HandleTime, x.AlertStatus })
.ToListAsync();
const int timelyMinutes = 30;
var acked = rows.Where(x => x.AckTime != null).ToList();
var handled = rows.Where(x => x.HandleTime != null &&
x.AlertStatus is AlertStatusEnum.Handled or AlertStatusEnum.Closed).ToList();
var result = new AlertTimelinessDto
{
Total = rows.Count,
AckCount = acked.Count,
HandledCount = handled.Count,
UnhandledCount = rows.Count(x => x.AlertStatus is AlertStatusEnum.Unacknowledged or AlertStatusEnum.Acknowledged),
AvgAckMinutes = acked.Count > 0
? Math.Round(acked.Average(x => (x.AckTime!.Value - x.FirstAlertTime).TotalMinutes), 1)
: null,
AvgHandleMinutes = handled.Count > 0
? Math.Round(handled.Average(x => (x.HandleTime!.Value - x.FirstAlertTime).TotalMinutes), 1)
: null,
TimelyRate = rows.Count > 0
? Math.Round(100.0 * acked.Count(x => (x.AckTime!.Value - x.FirstAlertTime).TotalMinutes <= timelyMinutes) / rows.Count, 1)
: 0
};
return Result<AlertTimelinessDto>.Success(result);
}
catch (Exception ex)
{
return Result<AlertTimelinessDto>.Error("查询及时率统计失败", ex);
}
}
#endregion
#region
/// <summary>
/// 时间范围缺省处理:未传时默认近 defaultDays 天
/// </summary>
private static (DateTime start, DateTime end) NormalizeRange(DateTime? startDate, DateTime? endDate, int defaultDays)
{
var end = endDate ?? DateTime.Now;
var start = startDate ?? end.AddDays(-defaultDays);
return (start, end);
}
/// <summary>
/// 时间分桶(day:yyyy-MM-dd / week:yyyy-Www / month:yyyy-MM
/// </summary>
private static string BucketOf(DateTime time, string granularity)
{
return (granularity ?? "day").ToLower() switch
{
"week" => $"{ISOWeek.GetYear(time)}-W{ISOWeek.GetWeekOfYear(time):D2}",
"month" => time.ToString("yyyy-MM"),
_ => time.ToString("yyyy-MM-dd")
};
}
/// <summary>
/// 告警状态中文标签
/// </summary>
public static string StatusLabel(AlertStatusEnum status) => status switch
{
AlertStatusEnum.Unacknowledged => "未确认",
AlertStatusEnum.Acknowledged => "已确认",
AlertStatusEnum.Handled => "已处理",
AlertStatusEnum.Closed => "已关闭",
AlertStatusEnum.Ignored => "已忽略",
AlertStatusEnum.ToWorkOrder => "已转工单",
_ => "未知"
};
/// <summary>
/// 告警类型中文标签
/// </summary>
private static string TypeLabel(AlertTypeEnum type) => type switch
{
AlertTypeEnum.ThresholdExceed => "阈值超限",
AlertTypeEnum.DeviceOffline => "设备离线",
AlertTypeEnum.CommFault => "通讯故障",
AlertTypeEnum.PatrolAbnormal => "巡检异常",
AlertTypeEnum.Custom => "自定义",
_ => "其它"
};
/// <summary>
/// 告警来源中文标签
/// </summary>
private static string SourceLabel(AlertSourceEnum source) => source switch
{
AlertSourceEnum.RuleEngine => "规则引擎",
AlertSourceEnum.AutoPatrol => "自动巡检",
AlertSourceEnum.Dashboard => "数据看板",
AlertSourceEnum.Collection => "数据采集",
AlertSourceEnum.Manual => "手动上报",
_ => "其它"
};
#endregion
}
}
@@ -0,0 +1,39 @@
using System.Threading.Channels;
namespace Service.Implement
{
/// <summary>
/// 告警通知事件(AlertId:告警消息IdIsNewAlert:是否新告警,false 表示合并触发的重复告警)
/// </summary>
/// <param name="AlertId">告警消息 Id</param>
/// <param name="IsNewAlert">是否新告警</param>
public record AlertNotifyEvent(long AlertId, bool IsNewAlert);
/// <summary>
/// 告警通知内存总线(生产者:AlertCenterService 上报告警后入队;消费者:AlertNotifyWorker 后台推送)
/// 采用静态单例 Channel,与项目 SqlSugarContext.DbContext 静态单例风格一致;
/// 进程重启丢失队列可接受(告警已落库,页面仍可查询处理)
/// </summary>
public static class AlertNotifyBus
{
private static readonly Channel<AlertNotifyEvent> _channel =
Channel.CreateUnbounded<AlertNotifyEvent>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false
});
/// <summary>
/// 队列读取端(供 AlertNotifyWorker 消费)
/// </summary>
public static ChannelReader<AlertNotifyEvent> Reader => _channel.Reader;
/// <summary>
/// 发布告警通知事件(非阻塞,队列满不会发生——无界队列)
/// </summary>
public static void Publish(long alertId, bool isNewAlert)
{
_channel.Writer.TryWrite(new AlertNotifyEvent(alertId, isNewAlert));
}
}
}
@@ -0,0 +1,591 @@
using Model;
using Model.Dto.Inspection;
using Model.Entity.Inspection;
using Model.Mapper;
using ORM;
using Service.Interface;
using SqlSugar;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Service.Implement
{
/// <summary>
/// 告警通知配置服务实现(渠道/通知规则/推送日志/值班排班/跨网发件箱)
/// </summary>
public class AlertNotifyService : IAlertNotifyService
{
private readonly IEnumerable<IAlertNotifier> _notifiers;
public AlertNotifyService(IEnumerable<IAlertNotifier> notifiers)
{
_notifiers = notifiers;
}
#region
/// <summary>
/// 渠道列表(Secret 脱敏,只返回 HasSecret 标记)
/// </summary>
public async Task<Result<List<AlertNotifyChannelDto>>> GetChannelsAsync()
{
try
{
var list = await SqlSugarContext.DbContext.Queryable<AlertNotifyChannelEntity>()
.Where(x => x.IsDel == 0)
.OrderBy(x => x.CreateTime, OrderByType.Desc)
.ToListAsync();
return Result<List<AlertNotifyChannelDto>>.Success(list.ToDtoList());
}
catch (Exception ex)
{
return Result<List<AlertNotifyChannelDto>>.Error("查询通知渠道失败", ex);
}
}
/// <summary>
/// 新增渠道
/// </summary>
public async Task<Result<bool>> AddChannelAsync(AlertNotifyChannelDto dto)
{
if (string.IsNullOrWhiteSpace(dto?.Name) || string.IsNullOrWhiteSpace(dto?.WebhookUrl))
{
return Result<bool>.Error("渠道名称与 Webhook 地址不能为空");
}
try
{
var entity = dto.ToEntity();
entity.Id = 0; // 防止前端误传 Id,新增一律由雪花生成
entity.CreateTime = DateTime.Now;
await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync();
return Result<bool>.Success(true);
}
catch (Exception ex)
{
return Result<bool>.Error("新增通知渠道失败", ex);
}
}
/// <summary>
/// 修改渠道(Secret 传空表示保持原值不覆盖——出口已脱敏,前端编辑时不回传明文)
/// </summary>
public async Task<Result<bool>> UpdateChannelAsync(AlertNotifyChannelDto dto)
{
var entity = dto?.ToEntity();
if (entity == null || entity.Id <= 0)
{
return Result<bool>.Error("渠道 Id 无效");
}
try
{
var updater = SqlSugarContext.DbContext.Updateable<AlertNotifyChannelEntity>()
.SetColumns(x => new AlertNotifyChannelEntity
{
ChannelType = entity.ChannelType,
Name = entity.Name,
WebhookUrl = entity.WebhookUrl,
PushMode = entity.PushMode,
IsEnabled = entity.IsEnabled,
Remark = entity.Remark
});
// Secret 显式传值时才覆盖(空表示不修改)
if (!string.IsNullOrWhiteSpace(entity.Secret))
{
updater = updater.SetColumns(x => x.Secret == entity.Secret);
}
var rows = await updater.Where(x => x.Id == entity.Id && x.IsDel == 0).ExecuteCommandAsync();
return Result<bool>.Success(rows > 0);
}
catch (Exception ex)
{
return Result<bool>.Error("修改通知渠道失败", ex);
}
}
/// <summary>
/// 删除渠道(软删除)
/// </summary>
public async Task<Result<bool>> DeleteChannelAsync(long id)
{
try
{
var rows = await SqlSugarContext.DbContext.Updateable<AlertNotifyChannelEntity>()
.SetColumns(x => x.IsDel == 1)
.Where(x => x.Id == id)
.ExecuteCommandAsync();
return Result<bool>.Success(rows > 0);
}
catch (Exception ex)
{
return Result<bool>.Error("删除通知渠道失败", ex);
}
}
/// <summary>
/// 渠道测试推送(发送测试消息验证 Webhook 配置,结果写入推送日志)
/// </summary>
public async Task<Result<bool>> TestSendAsync(long channelId)
{
try
{
var channel = await SqlSugarContext.DbContext.Queryable<AlertNotifyChannelEntity>()
.Where(x => x.Id == channelId && x.IsDel == 0)
.FirstAsync();
if (channel == null)
{
return Result<bool>.Error("渠道不存在或已被删除");
}
var notifier = _notifiers.FirstOrDefault(n => n.ChannelType == channel.ChannelType);
if (notifier == null)
{
return Result<bool>.Error($"暂不支持的渠道类型: {channel.ChannelType}");
}
var message = new AlertNotifyMessage
{
Title = "【测试】设备告警通知",
Content = $"这是一条来自 IOT 设备管理平台的测试消息。\n渠道: {channel.Name}\n时间: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"
};
NotifyResult sendResult;
if (channel.PushMode == NotifyPushModeEnum.Outbox)
{
// Outbox 模式:测试消息也走发件箱,由 AlertBridge 实际推送
await InsertOutboxAsync(0, channel, message, new List<string>());
sendResult = NotifyResult.Ok();
}
else
{
sendResult = await notifier.SendAsync(message, channel.WebhookUrl, channel.Secret);
}
await InsertLogAsync(0, channel, message.Title + " " + message.Content, sendResult, 0);
return sendResult.Success
? Result<bool>.Success(true)
: Result<bool>.Error($"测试推送失败: {sendResult.ErrorMsg}");
}
catch (Exception ex)
{
return Result<bool>.Error("测试推送失败", ex);
}
}
#endregion
#region
/// <summary>
/// 规则列表
/// </summary>
public async Task<Result<List<AlertNotifyRuleDto>>> GetRulesAsync()
{
try
{
var list = await SqlSugarContext.DbContext.Queryable<AlertNotifyRuleEntity>()
.Where(x => x.IsDel == 0)
.OrderBy(x => x.AlertLevel, OrderByType.Desc)
.OrderBy(x => x.CreateTime, OrderByType.Desc)
.ToListAsync();
return Result<List<AlertNotifyRuleDto>>.Success(list.ToDtoList());
}
catch (Exception ex)
{
return Result<List<AlertNotifyRuleDto>>.Error("查询通知规则失败", ex);
}
}
/// <summary>
/// 新增规则
/// </summary>
public async Task<Result<bool>> AddRuleAsync(AlertNotifyRuleDto dto)
{
if (string.IsNullOrWhiteSpace(dto?.RuleName))
{
return Result<bool>.Error("规则名称不能为空");
}
if (dto.ChannelId == null || !long.TryParse(dto.ChannelId, out var channelId) || channelId <= 0)
{
return Result<bool>.Error("请选择有效的通知渠道");
}
try
{
var entity = dto.ToEntity();
entity.Id = 0;
entity.CreateTime = DateTime.Now;
await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync();
return Result<bool>.Success(true);
}
catch (Exception ex)
{
return Result<bool>.Error("新增通知规则失败", ex);
}
}
/// <summary>
/// 修改规则
/// </summary>
public async Task<Result<bool>> UpdateRuleAsync(AlertNotifyRuleDto dto)
{
var entity = dto?.ToEntity();
if (entity == null || entity.Id <= 0)
{
return Result<bool>.Error("规则 Id 无效");
}
try
{
var rows = await SqlSugarContext.DbContext.Updateable(entity)
.IgnoreColumns(x => new { x.CreateTime, x.IsDel })
.ExecuteCommandAsync();
return Result<bool>.Success(rows > 0);
}
catch (Exception ex)
{
return Result<bool>.Error("修改通知规则失败", ex);
}
}
/// <summary>
/// 删除规则(软删除)
/// </summary>
public async Task<Result<bool>> DeleteRuleAsync(long id)
{
try
{
var rows = await SqlSugarContext.DbContext.Updateable<AlertNotifyRuleEntity>()
.SetColumns(x => x.IsDel == 1)
.Where(x => x.Id == id)
.ExecuteCommandAsync();
return Result<bool>.Success(rows > 0);
}
catch (Exception ex)
{
return Result<bool>.Error("删除通知规则失败", ex);
}
}
/// <summary>
/// 启用/停用规则
/// </summary>
public async Task<Result<bool>> SetRuleEnabledAsync(long id, bool enabled)
{
try
{
var rows = await SqlSugarContext.DbContext.Updateable<AlertNotifyRuleEntity>()
.SetColumns(x => x.IsEnabled == enabled)
.Where(x => x.Id == id)
.ExecuteCommandAsync();
return Result<bool>.Success(rows > 0);
}
catch (Exception ex)
{
return Result<bool>.Error("更新规则启用状态失败", ex);
}
}
#endregion
#region
/// <summary>
/// 推送日志分页(可按成功状态/渠道筛选)
/// </summary>
public async Task<Result<List<AlertNotifyLogDto>>> GetLogsPagedAsync(int pageIndex, int pageSize, RefAsync<int> total,
bool? success, long channelId = 0)
{
try
{
var list = await SqlSugarContext.DbContext.Queryable<AlertNotifyLogEntity>()
.Where(x => x.IsDel == 0)
.WhereIF(success.HasValue, x => x.Success == success!.Value)
.WhereIF(channelId > 0, x => x.ChannelId == channelId)
.OrderBy(x => x.NotifyTime, OrderByType.Desc)
.ToPageListAsync(pageIndex, pageSize, total);
return Result<List<AlertNotifyLogDto>>.Success(list.ToDtoList());
}
catch (Exception ex)
{
return Result<List<AlertNotifyLogDto>>.Error("查询推送日志失败", ex);
}
}
/// <summary>
/// 写入推送日志(内部公共方法,Worker 也复用)
/// </summary>
internal async Task InsertLogAsync(long alertId, AlertNotifyChannelEntity channel, string content, NotifyResult result, int retryCount)
{
try
{
await SqlSugarContext.DbContext.Insertable(new AlertNotifyLogEntity
{
AlertId = alertId,
ChannelType = channel.ChannelType,
ChannelId = channel.Id,
Target = MaskUrl(channel.WebhookUrl),
Content = content.Length > 1000 ? content.Substring(0, 1000) : content,
Success = result.Success,
ErrorMsg = result.ErrorMsg,
RetryCount = retryCount,
NotifyTime = DateTime.Now,
CreateTime = DateTime.Now
}).ExecuteCommandAsync();
}
catch
{
// 日志写入失败不影响主流程
}
}
/// <summary>
/// Webhook 地址脱敏(保留主机名与路径结构,隐藏末段令牌与查询参数)
/// 飞书令牌在路径末段(/hook/{token})、钉钉在 access_token、企微在 key,均需遮蔽,避免日志泄露密钥
/// </summary>
internal static string MaskUrl(string url)
{
if (string.IsNullOrWhiteSpace(url)) return url;
try
{
var uri = new Uri(url);
var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
// 保留除末段外的路径,末段(如飞书 hook 令牌)用 *** 代替;查询串一律丢弃
string maskedPath = segments.Length > 1
? "/" + string.Join("/", segments.Take(segments.Length - 1)) + "/***"
: "/***";
return $"{uri.Scheme}://{uri.Host}{maskedPath}";
}
catch
{
return url.Length > 60 ? url.Substring(0, 60) + "..." : url;
}
}
#endregion
#region
/// <summary>
/// 值班排班列表(按日期范围,缺省查本月)
/// </summary>
public async Task<Result<List<DutyRosterDto>>> GetDutiesAsync(DateTime? startDate, DateTime? endDate)
{
try
{
var start = startDate ?? new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
var end = endDate ?? start.AddMonths(1).AddDays(-1);
var list = await SqlSugarContext.DbContext.Queryable<DutyRosterEntity>()
.Where(x => x.IsDel == 0 && x.DutyDate >= start.Date && x.DutyDate <= end.Date)
.OrderBy(x => x.DutyDate, OrderByType.Asc)
.ToListAsync();
return Result<List<DutyRosterDto>>.Success(list.ToDtoList());
}
catch (Exception ex)
{
return Result<List<DutyRosterDto>>.Error("查询值班排班失败", ex);
}
}
/// <summary>
/// 新增排班
/// </summary>
public async Task<Result<bool>> AddDutyAsync(DutyRosterDto dto)
{
if (string.IsNullOrWhiteSpace(dto?.PersonName))
{
return Result<bool>.Error("值班人不能为空");
}
try
{
var entity = dto.ToEntity();
entity.Id = 0;
entity.CreateTime = DateTime.Now;
await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync();
return Result<bool>.Success(true);
}
catch (Exception ex)
{
return Result<bool>.Error("新增值班排班失败", ex);
}
}
/// <summary>
/// 修改排班
/// </summary>
public async Task<Result<bool>> UpdateDutyAsync(DutyRosterDto dto)
{
var entity = dto?.ToEntity();
if (entity == null || entity.Id <= 0)
{
return Result<bool>.Error("排班 Id 无效");
}
try
{
var rows = await SqlSugarContext.DbContext.Updateable(entity)
.IgnoreColumns(x => new { x.CreateTime, x.IsDel })
.ExecuteCommandAsync();
return Result<bool>.Success(rows > 0);
}
catch (Exception ex)
{
return Result<bool>.Error("修改值班排班失败", ex);
}
}
/// <summary>
/// 删除排班(软删除)
/// </summary>
public async Task<Result<bool>> DeleteDutyAsync(long id)
{
try
{
var rows = await SqlSugarContext.DbContext.Updateable<DutyRosterEntity>()
.SetColumns(x => x.IsDel == 1)
.Where(x => x.Id == id)
.ExecuteCommandAsync();
return Result<bool>.Success(rows > 0);
}
catch (Exception ex)
{
return Result<bool>.Error("删除值班排班失败", ex);
}
}
/// <summary>
/// 查询指定日期的值班人
/// </summary>
public async Task<DutyRosterEntity?> GetDutyByDateAsync(DateTime date)
{
return await SqlSugarContext.DbContext.Queryable<DutyRosterEntity>()
.Where(x => x.IsDel == 0 && x.DutyDate == date.Date)
.FirstAsync();
}
#endregion
#region AlertBridge
/// <summary>
/// 写入推送日志(供 AlertNotifyWorker 直连推送后复用)
/// </summary>
public async Task WriteLogAsync(long alertId, long channelId, string content, bool success, string? errorMsg, int retryCount)
{
var channel = await SqlSugarContext.DbContext.Queryable<AlertNotifyChannelEntity>()
.Where(x => x.Id == channelId)
.FirstAsync();
if (channel == null) return;
await InsertLogAsync(alertId, channel, content,
success ? NotifyResult.Ok() : NotifyResult.Fail(errorMsg ?? "未知错误"), retryCount);
}
/// <summary>
/// 写入跨网发件箱(供 AlertNotifyWorker 的 Outbox 模式复用)
/// </summary>
public async Task EnqueueOutboxAsync(long alertId, long channelId, AlertNotifyMessage message, List<string> atMobiles)
{
var channel = await SqlSugarContext.DbContext.Queryable<AlertNotifyChannelEntity>()
.Where(x => x.Id == channelId && x.IsDel == 0)
.FirstAsync();
if (channel == null) return;
await InsertOutboxAsync(alertId, channel, message, atMobiles);
}
/// <summary>
/// 拉取待推送发件箱(先查后标记;单跳板机实例场景足够,多实例并发时可改用数据库锁)
/// </summary>
public async Task<Result<List<AlertOutboxItemDto>>> PullOutboxAsync(int batch)
{
try
{
if (batch <= 0 || batch > 100) batch = 20;
var pending = await SqlSugarContext.DbContext.Queryable<AlertOutboxEntity>()
.Where(x => x.IsDel == 0 && x.Status == OutboxStatusEnum.Pending)
.OrderBy(x => x.CreateTime, OrderByType.Asc)
.Take(batch)
.ToListAsync();
if (pending.Count == 0)
{
return Result<List<AlertOutboxItemDto>>.Success(new List<AlertOutboxItemDto>());
}
// 标记已拉取(仅更新仍为 Pending 的记录,防止重复拉取)
var ids = pending.Select(x => x.Id).ToList();
await SqlSugarContext.DbContext.Updateable<AlertOutboxEntity>()
.SetColumns(x => new AlertOutboxEntity { Status = OutboxStatusEnum.Pulled, PullTime = DateTime.Now })
.Where(x => ids.Contains(x.Id) && x.Status == OutboxStatusEnum.Pending)
.ExecuteCommandAsync();
return Result<List<AlertOutboxItemDto>>.Success(pending.ToDtoList());
}
catch (Exception ex)
{
return Result<List<AlertOutboxItemDto>>.Error("拉取发件箱失败", ex);
}
}
/// <summary>
/// 回写发件箱推送结果(失败累加重试次数;结果同步写推送日志便于页面追溯)
/// </summary>
public async Task<Result<bool>> ReportOutboxResultAsync(long id, OutboxResultDto dto)
{
try
{
var item = await SqlSugarContext.DbContext.Queryable<AlertOutboxEntity>()
.Where(x => x.Id == id && x.IsDel == 0)
.FirstAsync();
if (item == null)
{
return Result<bool>.Error("发件箱记录不存在");
}
var status = dto?.Success == true ? OutboxStatusEnum.Success : OutboxStatusEnum.Failed;
// 表达式树不支持 ?. 空传播,先提取局部变量
bool success = dto?.Success == true;
string? resultMsg = dto?.ResultMsg;
await SqlSugarContext.DbContext.Updateable<AlertOutboxEntity>()
.SetColumns(x => new AlertOutboxEntity
{
Status = status,
FinishTime = DateTime.Now,
ResultMsg = resultMsg,
RetryCount = success ? x.RetryCount : x.RetryCount + 1
})
.Where(x => x.Id == id)
.ExecuteCommandAsync();
// 推送日志(渠道信息回查,失败忽略)
var channel = await SqlSugarContext.DbContext.Queryable<AlertNotifyChannelEntity>()
.Where(x => x.Id == item.ChannelId)
.FirstAsync();
if (channel != null)
{
await InsertLogAsync(item.AlertId, channel,
$"[跨网中转] {(dto?.Success == true ? "" : "")}",
dto?.Success == true ? NotifyResult.Ok() : NotifyResult.Fail(dto?.ResultMsg ?? "未知错误"),
item.RetryCount);
}
return Result<bool>.Success(true);
}
catch (Exception ex)
{
return Result<bool>.Error("回写发件箱结果失败", ex);
}
}
/// <summary>
/// 写入发件箱(Worker 的 Outbox 模式与渠道测试推送共用;Payload 自包含全部推送要素)
/// </summary>
internal async Task InsertOutboxAsync(long alertId, AlertNotifyChannelEntity channel, AlertNotifyMessage message, List<string> atMobiles)
{
var payload = new AlertOutboxPayload
{
ChannelType = (int)channel.ChannelType,
WebhookUrl = channel.WebhookUrl,
Secret = channel.Secret,
Title = message.Title,
Content = message.Content,
AtMobiles = atMobiles
};
await SqlSugarContext.DbContext.Insertable(new AlertOutboxEntity
{
AlertId = alertId,
ChannelId = channel.Id,
Payload = System.Text.Json.JsonSerializer.Serialize(payload),
Status = OutboxStatusEnum.Pending,
CreateTime = DateTime.Now
}).ExecuteCommandAsync();
}
#endregion
}
}

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