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>
This commit is contained in:
2026-09-10 17:31:39 +08:00
co-authored by Claude Sonnet 4.6
parent 3059befcf5
commit 6aca0297bb
48 changed files with 3583 additions and 52 deletions
@@ -3,6 +3,7 @@ using Model;
using Model.Dto.Config;
using Model.Entity.Config;
using ORM;
using Service.Interface;
using Service.Interface.Config;
using SqlSugar;
using System;
@@ -14,10 +15,18 @@ namespace Service.Implement.Config
/// <summary>
/// 设备指令下发 服务实现
/// 目前无网关运行时/采集引擎:每次调用先落一条设备日志(始终可验证),
/// 设备在线时才实际尝试 Modbus 写(GatewayCode 需为 "ip:port",默认 127.0.0.1:502)。
/// 设备在线时才实际尝试 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)
@@ -57,16 +66,26 @@ namespace Service.Implement.Config
: 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 写
var (host, port) = ParseGateway(device.GatewayCode);
using var modbus = new ModbusTcp();
modbus.ConfigureDevice(host, port, 3000, 3000);
bool connected = await modbus.ConnectAsync();
if (!connected)
{
await WriteLogAsync(device, "Error", "指令",
$"网关 {device.GatewayCode} 连接失败,指令未发送(点 {point.Name ?? point.Code} 值 {dto.Value}");
return Result.Error($"网关 {device.GatewayCode} 连接失败,指令未发送");
$"网关 {gateway.Code}({host}:{port}) 连接失败,指令未发送(点 {point.Name ?? point.Code} 值 {dto.Value}");
return Result.Error($"网关 {gateway.Code} 连接失败,指令未发送");
}
string desc;
@@ -95,6 +114,10 @@ namespace Service.Implement.Config
.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)
@@ -143,20 +166,6 @@ namespace Service.Implement.Config
return new[] { (ushort)(bits >> 16), (ushort)(bits & 0xFFFF) };
}
/// <summary>
/// 解析 GatewayCode 为 host:port;非法/缺失回退 127.0.0.1:502
/// </summary>
private static (string host, int port) ParseGateway(string? gatewayCode)
{
if (!string.IsNullOrWhiteSpace(gatewayCode))
{
var idx = gatewayCode.LastIndexOf(':');
if (idx > 0 && IPAddress.TryParse(gatewayCode[..idx], out _) && int.TryParse(gatewayCode[(idx + 1)..], out var p) && p > 0)
return (gatewayCode[..idx], p);
}
return ("127.0.0.1", 502);
}
/// <summary>
/// 写一条设备日志(设备独享日志,前端设备日志抽屉可见)
/// </summary>