diff --git a/IOT_API/Controllers/Config/GatewayController.cs b/IOT_API/Controllers/Config/GatewayController.cs index 3994ff0..a10ade9 100644 --- a/IOT_API/Controllers/Config/GatewayController.cs +++ b/IOT_API/Controllers/Config/GatewayController.cs @@ -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 { /// /// 网关管理 /// [ApiController] [Route("api/config/gateway")] + [RequirePermission("device:view")] public class GatewayController : ControllerBase { - // TODO: 实现 网关管理 相关接口 + private readonly IGatewayService _gatewayService; + + public GatewayController(IGatewayService gatewayService) + { + _gatewayService = gatewayService; + } + + /// + /// 网关列表(分页) + /// + [HttpGet("list")] + public async Task>> GetList([FromQuery] int pageIndex = 1, [FromQuery] int pageSize = 10, + [FromQuery] string? keyword = null, [FromQuery] int? protocolType = null) + { + var total = new RefAsync(); + var result = await _gatewayService.GetPagedAsync(pageIndex, pageSize, total, keyword, protocolType); + Response.Headers["X-Total-Count"] = total.Value.ToString(); + return result; + } + + /// + /// 网关详情 + /// + [HttpGet("{id}")] + public async Task> GetById(long id) + { + return await _gatewayService.GetByIdAsync(id); + } + + /// + /// 新增网关(需 device:edit 权限) + /// + [HttpPost] + [RequirePermission("device:edit")] + public async Task> Add([FromBody] GatewayDto dto) + { + return await _gatewayService.AddAsync(dto); + } + + /// + /// 修改网关(需 device:edit 权限) + /// + [HttpPut] + [RequirePermission("device:edit")] + public async Task> Update([FromBody] GatewayDto dto) + { + return await _gatewayService.UpdateAsync(dto); + } + + /// + /// 删除网关(软删除,设备 GatewayId 置 0;需 device:edit 权限) + /// + [HttpDelete("{id}")] + [RequirePermission("device:edit")] + public async Task Delete(long id) + { + return await _gatewayService.DeleteAsync(id); + } + + /// + /// 测试网关连接(TCP/串口尝试连接,更新在线状态) + /// + [HttpPost("{id}/test-connection")] + public async Task> TestConnection(long id) + { + return await _gatewayService.TestConnectionAsync(id); + } + + /// + /// 网关下拉选项(设备表单用) + /// + [HttpGet("options")] + public async Task>> GetOptions() + { + return await _gatewayService.GetOptionsAsync(); + } } } diff --git a/IOT_API/Controllers/Config/IotDeviceController.cs b/IOT_API/Controllers/Config/IotDeviceController.cs index 0c64249..f030f80 100644 --- a/IOT_API/Controllers/Config/IotDeviceController.cs +++ b/IOT_API/Controllers/Config/IotDeviceController.cs @@ -4,6 +4,7 @@ using Model.Dto.Config; using Service.Interface.Config; using SqlSugar; using System.Threading.Tasks; +using WebAPI.Filters; namespace WebAPI.Controllers { @@ -12,6 +13,7 @@ namespace WebAPI.Controllers /// [ApiController] [Route("api/config/device")] + [RequirePermission("device:view")] public class IotDeviceController : ControllerBase { private readonly IDeviceService _deviceService; @@ -30,11 +32,12 @@ namespace WebAPI.Controllers /// 每页数量(默认10) /// 关键字(模糊匹配设备编号/名称/类型) /// 所属产品Id(0 表示不过滤) + /// 所属网关Id(0 表示不过滤) [HttpGet("list")] - public async Task GetList(int pageIndex = 1, int pageSize = 10, string? keyword = null, long productId = 0) + public async Task GetList(int pageIndex = 1, int pageSize = 10, string? keyword = null, long productId = 0, long gatewayId = 0) { RefAsync total = 0; - var result = await _deviceService.GetPagedAsync(pageIndex, pageSize, total, keyword, productId > 0 ? productId : null); + 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>.Success(result.Data)) @@ -52,28 +55,31 @@ namespace WebAPI.Controllers } /// - /// 新增设备 + /// 新增设备(需 device:edit 权限) /// [HttpPost] + [RequirePermission("device:edit")] public async Task Add([FromBody] IotDeviceDto dto) { return Ok(await _deviceService.AddAsync(dto)); } /// - /// 修改设备 + /// 修改设备(需 device:edit 权限) /// [HttpPut] + [RequirePermission("device:edit")] public async Task Update([FromBody] IotDeviceDto dto) { return Ok(await _deviceService.UpdateAsync(dto)); } /// - /// 删除设备(软删除) + /// 删除设备(软删除;需 device:edit 权限) /// /// 设备主键 Id [HttpDelete("{id}")] + [RequirePermission("device:edit")] public async Task Delete(long id) { return Ok(await _deviceService.DeleteAsync(id)); @@ -97,11 +103,12 @@ namespace WebAPI.Controllers } /// - /// 按物模型可写点向设备下发指令 + /// 按物模型可写点向设备下发指令(需 device:control 权限) /// /// 设备主键 Id /// 指令请求(PointId 点Id + Value 工程值 + IsSimulated 是否模拟) [HttpPost("{id}/command")] + [RequirePermission("device:control")] public async Task SendCommand(long id, [FromBody] DeviceCommandDto dto) { if (dto == null) diff --git a/IOT_API/Controllers/System/AuditController.cs b/IOT_API/Controllers/System/AuditController.cs index 8b1fea8..9feb61a 100644 --- a/IOT_API/Controllers/System/AuditController.cs +++ b/IOT_API/Controllers/System/AuditController.cs @@ -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 { /// - /// 操作审计日志 + /// 操作审计日志(满足实验室审计要求;需 user:manage 权限) /// [ApiController] [Route("api/system/audit")] + [RequirePermission("user:manage")] public class AuditController : ControllerBase { - // TODO: 实现 操作审计日志 相关接口 + private readonly IAuditLogService _auditLogService; + + public AuditController(IAuditLogService auditLogService) + { + _auditLogService = auditLogService; + } + + /// + /// 审计日志列表(分页;筛选:操作人/描述关键字、操作类型、操作对象、时间段) + /// + [HttpGet("list")] + public async Task GetList(int pageIndex = 1, int pageSize = 20, + string? keyword = null, string? operationType = null, string? operationTarget = null, + DateTime? startTime = null, DateTime? endTime = null) + { + RefAsync 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>.Success(result.Data)) + : Ok(Result>.Error(result.Msg)); + } } } diff --git a/IOT_API/Controllers/System/AuthController.cs b/IOT_API/Controllers/System/AuthController.cs new file mode 100644 index 0000000..164dee9 --- /dev/null +++ b/IOT_API/Controllers/System/AuthController.cs @@ -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 +{ + /// + /// 认证授权(登录/登出/当前用户/改密) + /// + [ApiController] + [Route("api/system/auth")] + public class AuthController : ControllerBase + { + private readonly IAuthService _authService; + + public AuthController(IAuthService authService) + { + _authService = authService; + } + + /// + /// 登录(用户名+密码,成功返回 Token 与权限列表) + /// + [HttpPost("login")] + [AllowAnonymous] + public async Task Login([FromBody] LoginDto dto) + { + var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); + return Ok(await _authService.LoginAsync(dto, ip)); + } + + /// + /// 退出登录(记录审计日志) + /// + [HttpPost("logout")] + [Authorize] + public async Task Logout() + { + return Ok(await _authService.LogoutAsync()); + } + + /// + /// 当前登录用户信息 + 权限列表 + /// + [HttpGet("me")] + [Authorize] + public async Task Me() + { + return Ok(await _authService.GetMeAsync()); + } + + /// + /// 修改自己的密码 + /// + [HttpPost("change-password")] + [Authorize] + public async Task ChangePassword([FromBody] ChangePasswordDto dto) + { + return Ok(await _authService.ChangePasswordAsync(dto)); + } + } +} diff --git a/IOT_API/Controllers/System/OrganizationController.cs b/IOT_API/Controllers/System/OrganizationController.cs index 7a29643..5388f96 100644 --- a/IOT_API/Controllers/System/OrganizationController.cs +++ b/IOT_API/Controllers/System/OrganizationController.cs @@ -1,14 +1,69 @@ using Microsoft.AspNetCore.Mvc; +using Model; +using Model.Dto.System; +using Service.Interface; +using WebAPI.Filters; namespace WebAPI.Controllers { /// - /// 组织架构管理 + /// 组织架构管理(公司/实验室/部门/班组树;需 user:manage 权限) /// [ApiController] [Route("api/system/organization")] + [RequirePermission("user:manage")] public class OrganizationController : ControllerBase { - // TODO: 实现 组织架构管理 相关接口 + private readonly IOrgService _orgService; + + public OrganizationController(IOrgService orgService) + { + _orgService = orgService; + } + + /// + /// 完整组织树(嵌套子级,含班组白/夜班) + /// + [HttpGet("tree")] + public async Task GetTree() + { + return Ok(await _orgService.GetTreeAsync()); + } + + /// + /// 组织下拉选项(平铺) + /// + [HttpGet("options")] + public async Task GetOptions() + { + return Ok(await _orgService.GetOptionsAsync()); + } + + /// + /// 新增组织节点(ParentId 传父级Id,根节点传 "0") + /// + [HttpPost] + public async Task Add([FromBody] OrgDto dto) + { + return Ok(await _orgService.AddAsync(dto)); + } + + /// + /// 修改组织节点 + /// + [HttpPut] + public async Task Update([FromBody] OrgDto dto) + { + return Ok(await _orgService.UpdateAsync(dto)); + } + + /// + /// 删除组织节点(有子级或挂靠用户时不可删) + /// + [HttpDelete("{id}")] + public async Task Delete(long id) + { + return Ok(await _orgService.DeleteAsync(id)); + } } } diff --git a/IOT_API/Controllers/System/RoleController.cs b/IOT_API/Controllers/System/RoleController.cs index ded3e73..a14048e 100644 --- a/IOT_API/Controllers/System/RoleController.cs +++ b/IOT_API/Controllers/System/RoleController.cs @@ -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 { /// - /// 角色权限管理 + /// 角色权限管理(需 user:manage 权限) /// [ApiController] [Route("api/system/role")] + [RequirePermission("user:manage")] public class RoleController : ControllerBase { - // TODO: 实现 角色权限管理 相关接口 + private readonly IRoleService _roleService; + + public RoleController(IRoleService roleService) + { + _roleService = roleService; + } + + /// + /// 角色列表(分页) + /// + [HttpGet("list")] + public async Task GetList(int pageIndex = 1, int pageSize = 10, string? keyword = null) + { + RefAsync total = 0; + var result = await _roleService.GetPagedAsync(pageIndex, pageSize, total, keyword); + Response.Headers["X-Total-Count"] = total.Value.ToString(); + return result.IsSuccess + ? Ok(Result>.Success(result.Data)) + : Ok(Result>.Error(result.Msg)); + } + + /// + /// 角色下拉选项 + /// + [HttpGet("options")] + public async Task GetOptions() + { + return Ok(await _roleService.GetOptionsAsync()); + } + + /// + /// 全部权限列表(分配权限用) + /// + [HttpGet("permissions")] + public async Task GetPermissions() + { + return Ok(await _roleService.GetPermissionsAsync()); + } + + /// + /// 角色详情(含权限Id列表) + /// + [HttpGet("{id}")] + public async Task GetById(long id) + { + return Ok(await _roleService.GetByIdAsync(id)); + } + + /// + /// 新增角色(PermissionIds 传权限) + /// + [HttpPost] + public async Task Add([FromBody] RoleDto dto) + { + return Ok(await _roleService.AddAsync(dto)); + } + + /// + /// 修改角色(PermissionIds 传 null 不改权限) + /// + [HttpPut] + public async Task Update([FromBody] RoleDto dto) + { + return Ok(await _roleService.UpdateAsync(dto)); + } + + /// + /// 删除角色(系统内置角色不可删) + /// + [HttpDelete("{id}")] + public async Task Delete(long id) + { + return Ok(await _roleService.DeleteAsync(id)); + } + + /// + /// 分配权限(全量覆盖) + /// + [HttpPost("{id}/permissions")] + public async Task AssignPermissions(long id, [FromBody] List permissionIds) + { + var ids = permissionIds?.Select(long.Parse).Where(p => p > 0).ToList() ?? new List(); + return Ok(await _roleService.AssignPermissionsAsync(id, ids)); + } } } diff --git a/IOT_API/Controllers/System/UserController.cs b/IOT_API/Controllers/System/UserController.cs index 708c00c..78b51a4 100644 --- a/IOT_API/Controllers/System/UserController.cs +++ b/IOT_API/Controllers/System/UserController.cs @@ -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 { /// - /// 用户管理 + /// 用户管理(需 user:manage 权限) /// [ApiController] [Route("api/system/user")] + [RequirePermission("user:manage")] public class UserController : ControllerBase { - // TODO: 实现 用户管理 相关接口 + private readonly IUserService _userService; + + public UserController(IUserService userService) + { + _userService = userService; + } + + /// + /// 用户列表(分页,关键字匹配用户名/姓名,可按角色、组织节点筛选;受数据范围约束) + /// + [HttpGet("list")] + public async Task GetList(int pageIndex = 1, int pageSize = 10, string? keyword = null, long roleId = 0, long orgId = 0) + { + RefAsync 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>.Success(result.Data)) + : Ok(Result>.Error(result.Msg)); + } + + /// + /// 用户下拉选项(启用中的用户) + /// + [HttpGet("options")] + public async Task GetOptions() + { + return Ok(await _userService.GetOptionsAsync()); + } + + /// + /// 用户详情 + /// + [HttpGet("{id}")] + public async Task GetById(long id) + { + return Ok(await _userService.GetByIdAsync(id)); + } + + /// + /// 新增用户(InitialPassword 传初始密码,RoleIds 传角色) + /// + [HttpPost] + public async Task Add([FromBody] UserDto dto) + { + return Ok(await _userService.AddAsync(dto)); + } + + /// + /// 修改用户(RoleIds 传 null 不改角色;密码走重置接口) + /// + [HttpPut] + public async Task Update([FromBody] UserDto dto) + { + return Ok(await _userService.UpdateAsync(dto)); + } + + /// + /// 删除用户(软删除) + /// + [HttpDelete("{id}")] + public async Task Delete(long id) + { + return Ok(await _userService.DeleteAsync(id)); + } + + /// + /// 分配角色(全量覆盖) + /// + [HttpPost("{id}/roles")] + public async Task AssignRoles(long id, [FromBody] List roleIds) + { + var ids = roleIds?.Select(long.Parse).Where(r => r > 0).ToList() ?? new List(); + return Ok(await _userService.AssignRolesAsync(id, ids)); + } + + /// + /// 重置用户密码 + /// + [HttpPost("{id}/reset-password")] + public async Task 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)); + } + + /// + /// 启用/禁用用户 + /// + [HttpPost("{id}/enabled")] + public async Task 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; + } } } diff --git a/IOT_API/Filters/RequirePermissionAttribute.cs b/IOT_API/Filters/RequirePermissionAttribute.cs new file mode 100644 index 0000000..60e4910 --- /dev/null +++ b/IOT_API/Filters/RequirePermissionAttribute.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Model; + +namespace WebAPI.Filters +{ + /// + /// 权限校验过滤器:标注在 Controller 或 Action 上,执行前校验当前用户(JWT Claims)是否拥有指定权限 + /// 用法:[RequirePermission("device:edit")] + /// 权限编码在登录时随 JWT 写入 Claims,此处只读 Claims,不查库 + /// + [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() + : 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; + } + } +} diff --git a/IOT_API/IOT_API.csproj b/IOT_API/IOT_API.csproj index 7a92342..088a3c4 100644 --- a/IOT_API/IOT_API.csproj +++ b/IOT_API/IOT_API.csproj @@ -8,6 +8,7 @@ + diff --git a/IOT_API/Program.cs b/IOT_API/Program.cs index 804d2f3..471a535 100644 --- a/IOT_API/Program.cs +++ b/IOT_API/Program.cs @@ -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; @@ -32,6 +37,9 @@ namespace WebAPI DatabaseConfig.CreateDatabaseAndCheckConnection(createDatabase: true, checkConnection: true); SqlSugarContext.InitDatabase(); + // RBAC 数据种子:预定义角色/权限/默认管理员(幂等) + DataSeeder.Seed(); + // 飞书自建应用凭证:用于告警 @ 时把接收人手机号转成 open_id(webhook 机器人自身无此能力) var feishu = builder.Configuration.GetSection("Feishu"); Common.Notify.FeishuConfig.Init(feishu["AppId"] ?? "", feishu["AppSecret"] ?? ""); @@ -51,6 +59,28 @@ namespace WebAPI // 自动注册业务服务(Service.Interface -> Service.Implement) builder.Services.AddBusinessServices(); + // 当前用户上下文(从 JWT Claims 解析,供业务服务取操作人/做权限判断) + builder.Services.AddHttpContextAccessor(); + builder.Services.AddScoped(); + + // 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(); @@ -74,6 +104,8 @@ namespace WebAPI // 导致前端 vite 代理被 CORS 拦截(Network Error);上位机/现场部署通常也只用 http // app.UseHttpsRedirection(); + app.UseAuthentication(); + app.UseAuthorization(); // 静态文件:设备附件上传后通过 /uploads/... 访问(存储于 wwwroot) diff --git a/IOT_API/Services/CurrentUser.cs b/IOT_API/Services/CurrentUser.cs new file mode 100644 index 0000000..b7b62ef --- /dev/null +++ b/IOT_API/Services/CurrentUser.cs @@ -0,0 +1,62 @@ +using System.Security.Claims; +using Service.Interface; + +namespace WebAPI.Services +{ + /// + /// 当前登录用户上下文实现:从 JWT Claims(HttpContext.User)解析 + /// + 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 RoleIds => ParseList("roleIds").Select(long.Parse).ToList(); + + public List RoleNames => ParseList("roleNames"); + + public List 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 ParseList(string claimType) + { + var raw = Principal?.FindFirst(claimType)?.Value; + if (string.IsNullOrWhiteSpace(raw)) return new List(); + return raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + } + } +} diff --git a/Model/Dto/Config/GatewayDto.cs b/Model/Dto/Config/GatewayDto.cs new file mode 100644 index 0000000..9d60de7 --- /dev/null +++ b/Model/Dto/Config/GatewayDto.cs @@ -0,0 +1,56 @@ +namespace Model.Dto.Config +{ + /// + /// 网关 DTO + /// + 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; } + } + + /// + /// 网关下拉选项 DTO(设备表单选用) + /// + public class GatewayOptionDto + { + public string Id { get; set; } + public string Code { get; set; } + public string Name { get; set; } + } + + /// + /// 测试连接结果 DTO + /// + public class GatewayTestResultDto + { + public bool Success { get; set; } + public string Message { get; set; } + } +} diff --git a/Model/Dto/Config/IotDeviceDto.cs b/Model/Dto/Config/IotDeviceDto.cs index 0bdc35f..74942ff 100644 --- a/Model/Dto/Config/IotDeviceDto.cs +++ b/Model/Dto/Config/IotDeviceDto.cs @@ -25,8 +25,11 @@ namespace Model.Dto.Config /// 所属产品型号/名称(只读展示) public string? ProductName { get; set; } - /// 所属网关Code - public string GatewayCode { get; set; } + /// 所属网关Id(string,0=未分配) + public string GatewayId { get; set; } + + /// 所属网关名称(只读展示) + public string? GatewayName { get; set; } /// 从站地址 public byte SlaveId { get; set; } diff --git a/Model/Dto/System/OrgDto.cs b/Model/Dto/System/OrgDto.cs new file mode 100644 index 0000000..951fe5f --- /dev/null +++ b/Model/Dto/System/OrgDto.cs @@ -0,0 +1,56 @@ +namespace Model.Dto.System +{ + /// + /// 组织架构树节点 DTO(嵌套子级;含数据权限摘要) + /// + 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 Children { get; set; } = new(); + + /// 直接挂靠用户数 + public int UserCount { get; set; } + + /// 本组织及下级用户总数 + public int TotalUserCount { get; set; } + + /// 数据权限摘要:本组织及下级用户的角色+数据范围(去重,如「实验室管理员·本组织及下级」) + public List ScopeSummary { get; set; } = new(); + } + + /// + /// 组织架构编辑 DTO(新增/修改用) + /// + 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; } + } + + /// + /// 组织下拉选项 DTO(用户表单选组织用) + /// + public class OrgOptionDto + { + public string Id { get; set; } + public string Name { get; set; } + public int OrgType { get; set; } + } +} diff --git a/Model/Dto/System/PermissionDto.cs b/Model/Dto/System/PermissionDto.cs new file mode 100644 index 0000000..5acdd72 --- /dev/null +++ b/Model/Dto/System/PermissionDto.cs @@ -0,0 +1,37 @@ +namespace Model.Dto.System +{ + /// + /// 权限 DTO + /// + 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; } + } + + /// + /// 审计日志 DTO + /// + 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; } + } +} diff --git a/Model/Dto/System/RoleDto.cs b/Model/Dto/System/RoleDto.cs new file mode 100644 index 0000000..13aec07 --- /dev/null +++ b/Model/Dto/System/RoleDto.cs @@ -0,0 +1,31 @@ +namespace Model.Dto.System +{ + /// + /// 角色 DTO + /// + 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; } + /// 角色的权限Id列表(分配权限用) + public List? PermissionIds { get; set; } + /// 角色的权限编码列表(展示用) + public List? PermissionCodes { get; set; } + } + + /// + /// 角色下拉选项 DTO + /// + public class RoleOptionDto + { + public string Id { get; set; } + public string Code { get; set; } + public string Name { get; set; } + } +} diff --git a/Model/Dto/System/UserDto.cs b/Model/Dto/System/UserDto.cs new file mode 100644 index 0000000..2ef2b1d --- /dev/null +++ b/Model/Dto/System/UserDto.cs @@ -0,0 +1,68 @@ +namespace Model.Dto.System +{ + /// + /// 用户 DTO + /// + 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; } + /// 用户的角色Id列表(分配角色用) + public List? RoleIds { get; set; } + /// 用户的角色名称(展示用,逗号分隔) + public string? RoleNames { get; set; } + /// 初始密码(仅新增用户时传入,不回显) + public string? InitialPassword { get; set; } + } + + /// + /// 登录请求 DTO + /// + public class LoginDto + { + public string UserName { get; set; } + public string Password { get; set; } + } + + /// + /// 登录结果 DTO + /// + public class LoginResultDto + { + public string Token { get; set; } + public string RefreshToken { get; set; } + public UserDto User { get; set; } + public List Permissions { get; set; } = new(); + } + + /// + /// 修改密码 DTO + /// + public class ChangePasswordDto + { + public string OldPassword { get; set; } + public string NewPassword { get; set; } + } + + /// + /// 用户下拉选项 DTO + /// + public class UserOptionDto + { + public string Id { get; set; } + public string UserName { get; set; } + public string? RealName { get; set; } + } +} diff --git a/Model/Entity/Config/GatewayEntity.cs b/Model/Entity/Config/GatewayEntity.cs new file mode 100644 index 0000000..dc34899 --- /dev/null +++ b/Model/Entity/Config/GatewayEntity.cs @@ -0,0 +1,127 @@ +using SqlSugar; + +namespace Model.Entity.Config +{ + /// + /// 网关(通讯通道):TCP 类存 IP+端口,串口类存 COM+波特率,S7 类额外存 CPU/Rack/Slot。 + /// 同一网关下多设备用从站号(SlaveId)区分。 + /// + public class GatewayEntity : BaseEntity + { + /// + /// 网关编码(唯一,如 GW-ENV-01) + /// + [SugarColumn(ColumnDescription = "网关编码", Length = 64)] + public string Code { get; set; } + + /// + /// 网关名称 + /// + [SugarColumn(ColumnDescription = "网关名称", Length = 100)] + public string Name { get; set; } + + /// + /// 通讯协议类型(决定连接参数组:TCP/Serial/S7) + /// + [SugarColumn(ColumnDescription = "通讯协议类型")] + public IotDeviceProtocolEnum ProtocolType { get; set; } = IotDeviceProtocolEnum.ModbusTcp; + + #region TCP 参数(ModbusTcp / Tcp / S7 协议使用) + /// + /// IP 地址 + /// + [SugarColumn(ColumnDescription = "IP地址", Length = 50, IsNullable = true)] + public string? Host { get; set; } + + /// + /// 端口号(ModbusTcp 默认 502,S7 默认 102) + /// + [SugarColumn(ColumnDescription = "端口号", IsNullable = true)] + public int? Port { get; set; } + #endregion + + #region 串口参数(ModbusRtu / Serial 协议使用) + /// + /// 串口号(如 COM3) + /// + [SugarColumn(ColumnDescription = "串口号", Length = 20, IsNullable = true)] + public string? ComPort { get; set; } + + /// + /// 波特率(默认 9600) + /// + [SugarColumn(ColumnDescription = "波特率", IsNullable = true)] + public int? BaudRate { get; set; } + + /// + /// 数据位(默认 8) + /// + [SugarColumn(ColumnDescription = "数据位", IsNullable = true)] + public byte? DataBits { get; set; } + + /// + /// 停止位(1/2,默认 1) + /// + [SugarColumn(ColumnDescription = "停止位", IsNullable = true)] + public byte? StopBits { get; set; } + + /// + /// 校验位(0=None 1=Odd 2=Even,默认 0) + /// + [SugarColumn(ColumnDescription = "校验位", IsNullable = true)] + public byte? Parity { get; set; } + #endregion + + #region S7 参数(S7 协议专用) + /// + /// S7 CPU 型号(如 S71200、S71500) + /// + [SugarColumn(ColumnDescription = "S7 CPU型号", Length = 20, IsNullable = true)] + public string? CpuType { get; set; } + + /// + /// S7 Rack(默认 0) + /// + [SugarColumn(ColumnDescription = "S7 Rack", IsNullable = true)] + public byte? Rack { get; set; } + + /// + /// S7 Slot(默认 1) + /// + [SugarColumn(ColumnDescription = "S7 Slot", IsNullable = true)] + public byte? Slot { get; set; } + #endregion + + #region 状态 + /// + /// 是否启用 + /// + [SugarColumn(ColumnDescription = "是否启用")] + public bool IsEnabled { get; set; } = true; + + /// + /// 在线状态(0=离线 1=在线 3=异常,由测试连接更新) + /// + [SugarColumn(ColumnDescription = "在线状态")] + public byte OnlineStatus { get; set; } + + /// + /// 最后连接成功时间 + /// + [SugarColumn(ColumnDescription = "最后连接时间", IsNullable = true)] + public DateTime? LastConnectedTime { get; set; } + + /// + /// 最近错误 + /// + [SugarColumn(ColumnDescription = "最近错误", Length = 500, IsNullable = true)] + public string? LastError { get; set; } + #endregion + + /// + /// 备注 + /// + [SugarColumn(ColumnDescription = "备注", Length = 500, IsNullable = true)] + public string? Remark { get; set; } + } +} diff --git a/Model/Entity/Config/IotDeviceEntity.cs b/Model/Entity/Config/IotDeviceEntity.cs index ac979ae..6c4e97a 100644 --- a/Model/Entity/Config/IotDeviceEntity.cs +++ b/Model/Entity/Config/IotDeviceEntity.cs @@ -34,10 +34,10 @@ namespace Model.Entity.Config public long ProductId { get; set; } /// - /// 所属网关Code(对应网关实体/通道的标识,连接时据此刻查找 IP/端口/串口) + /// 所属网关Id(关联 GatewayEntity.Id,0=未分配) /// - [SugarColumn(ColumnDescription = "所属网关Code", Length = 64)] - public string GatewayCode { get; set; } + [SugarColumn(ColumnDescription = "所属网关Id", DefaultValue = "0")] + public long GatewayId { get; set; } /// /// 从站地址(协议从站号,网关下区分设备) diff --git a/Model/Entity/System/AuditLogEntity.cs b/Model/Entity/System/AuditLogEntity.cs new file mode 100644 index 0000000..df5161c --- /dev/null +++ b/Model/Entity/System/AuditLogEntity.cs @@ -0,0 +1,50 @@ +using SqlSugar; + +namespace Model.Entity.System +{ + /// + /// 审计日志表(记录所有关键操作) + /// + [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 = "操作人角色Id(0=无/多角色)", 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 = "操作对象Id(0=无具体Id)", DefaultValue = "0")] + public long TargetId { get; set; } + + [SugarColumn(ColumnName = "DeviceId", ColumnDescription = "关联设备Id(0=无关设备)", 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; } + } +} diff --git a/Model/Entity/System/OrgEntity.cs b/Model/Entity/System/OrgEntity.cs new file mode 100644 index 0000000..441dd78 --- /dev/null +++ b/Model/Entity/System/OrgEntity.cs @@ -0,0 +1,40 @@ +using SqlSugar; + +namespace Model.Entity.System +{ + /// + /// 组织架构表(树形:公司/实验室/部门/班组,ParentId 自关联;班组带白/夜班) + /// + [SugarTable("sys_org")] + public class OrgEntity : BaseEntity + { + [SugarColumn(ColumnName = "ParentId", ColumnDescription = "父级组织Id(0=根节点)", 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; } + + /// 组织类型:1=公司, 2=实验室, 3=部门, 4=班组 + [SugarColumn(ColumnName = "OrgType", ColumnDescription = "组织类型(1公司/2实验室/3部门/4班组)", ColumnDataType = "smallint", DefaultValue = "3")] + public byte OrgType { get; set; } + + /// 班组班次:0=非班组, 1=白班, 2=夜班 + [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; } + } +} diff --git a/Model/Entity/System/PermissionEntity.cs b/Model/Entity/System/PermissionEntity.cs new file mode 100644 index 0000000..0b37971 --- /dev/null +++ b/Model/Entity/System/PermissionEntity.cs @@ -0,0 +1,26 @@ +using SqlSugar; + +namespace Model.Entity.System +{ + /// + /// 权限表(功能权限码,如 device:view / device:edit / alert:confirm) + /// + [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; } + } +} diff --git a/Model/Entity/System/RoleEntity.cs b/Model/Entity/System/RoleEntity.cs new file mode 100644 index 0000000..f2a71f3 --- /dev/null +++ b/Model/Entity/System/RoleEntity.cs @@ -0,0 +1,29 @@ +using SqlSugar; + +namespace Model.Entity.System +{ + /// + /// 角色表 + /// + [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; } + } +} diff --git a/Model/Entity/System/RolePermissionEntity.cs b/Model/Entity/System/RolePermissionEntity.cs new file mode 100644 index 0000000..e864700 --- /dev/null +++ b/Model/Entity/System/RolePermissionEntity.cs @@ -0,0 +1,17 @@ +using SqlSugar; + +namespace Model.Entity.System +{ + /// + /// 角色-权限关联表(多对多) + /// + [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; } + } +} diff --git a/Model/Entity/System/UserEntity.cs b/Model/Entity/System/UserEntity.cs new file mode 100644 index 0000000..abdd7e4 --- /dev/null +++ b/Model/Entity/System/UserEntity.cs @@ -0,0 +1,47 @@ +using SqlSugar; + +namespace Model.Entity.System +{ + /// + /// 用户表 + /// + [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; } + } +} diff --git a/Model/Entity/System/UserRoleEntity.cs b/Model/Entity/System/UserRoleEntity.cs new file mode 100644 index 0000000..34d5359 --- /dev/null +++ b/Model/Entity/System/UserRoleEntity.cs @@ -0,0 +1,17 @@ +using SqlSugar; + +namespace Model.Entity.System +{ + /// + /// 用户-角色关联表(多对多) + /// + [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; } + } +} diff --git a/Model/Mapper/EntityMapper.cs b/Model/Mapper/EntityMapper.cs index 88d2b1c..ea6585d 100644 --- a/Model/Mapper/EntityMapper.cs +++ b/Model/Mapper/EntityMapper.cs @@ -1,9 +1,11 @@ using Model.Dto.Asset; using Model.Dto.Config; using Model.Dto.Inspection; +using Model.Dto.System; using Model.Entity.Asset; using Model.Entity.Config; using Model.Entity.Inspection; +using Model.Entity.System; namespace Model.Mapper { @@ -410,7 +412,7 @@ namespace Model.Mapper Name = entity.Name, DeviceType = entity.DeviceType, ProductId = entity.ProductId.ToString(), - GatewayCode = entity.GatewayCode, + GatewayId = entity.GatewayId.ToString(), SlaveId = entity.SlaveId, Manufacturer = entity.Manufacturer, Description = entity.Description, @@ -445,7 +447,7 @@ namespace Model.Mapper Name = dto.Name, DeviceType = dto.DeviceType, ProductId = ParseId(dto.ProductId), - GatewayCode = dto.GatewayCode, + GatewayId = ParseId(dto.GatewayId), SlaveId = dto.SlaveId, Manufacturer = dto.Manufacturer, Description = dto.Description, @@ -485,6 +487,99 @@ namespace Model.Mapper } #endregion + #region 网关 + /// + /// GatewayEntity → GatewayDto + /// + public static GatewayDto ToDto(this GatewayEntity entity) + { + if (entity == null) return null; + return new GatewayDto + { + Id = entity.Id.ToString(), + Code = entity.Code, + Name = entity.Name, + ProtocolType = (int)entity.ProtocolType, + Host = entity.Host, + Port = entity.Port, + ComPort = entity.ComPort, + BaudRate = entity.BaudRate, + DataBits = entity.DataBits, + StopBits = entity.StopBits, + Parity = entity.Parity, + CpuType = entity.CpuType, + Rack = entity.Rack, + Slot = entity.Slot, + IsEnabled = entity.IsEnabled, + OnlineStatus = entity.OnlineStatus, + LastConnectedTime = entity.LastConnectedTime, + LastError = entity.LastError, + Remark = entity.Remark, + CreateTime = entity.CreateTime + }; + } + + /// + /// List<GatewayEntity> → List<GatewayDto> + /// + public static List ToDtoList(this List entities) + { + return entities?.Select(e => e.ToDto()).ToList() ?? new List(); + } + + /// + /// GatewayDto → GatewayEntity(入参映射) + /// + public static GatewayEntity ToEntity(this GatewayDto dto) + { + if (dto == null) return null; + return new GatewayEntity + { + Id = ParseId(dto.Id), + Code = dto.Code, + Name = dto.Name, + ProtocolType = (IotDeviceProtocolEnum)dto.ProtocolType, + Host = dto.Host, + Port = dto.Port, + ComPort = dto.ComPort, + BaudRate = dto.BaudRate, + DataBits = dto.DataBits, + StopBits = dto.StopBits, + Parity = dto.Parity, + CpuType = dto.CpuType, + Rack = dto.Rack, + Slot = dto.Slot, + IsEnabled = dto.IsEnabled, + OnlineStatus = dto.OnlineStatus, + LastConnectedTime = dto.LastConnectedTime, + LastError = dto.LastError, + Remark = dto.Remark + }; + } + + /// + /// GatewayEntity → GatewayOptionDto(下拉选项) + /// + public static GatewayOptionDto ToOptionDto(this GatewayEntity entity) + { + if (entity == null) return null; + return new GatewayOptionDto + { + Id = entity.Id.ToString(), + Code = entity.Code, + Name = entity.Name + }; + } + + /// + /// List<GatewayEntity> → List<GatewayOptionDto> + /// + public static List ToOptionDtoList(this List entities) + { + return entities?.Select(e => e.ToOptionDto()).ToList() ?? new List(); + } + #endregion + #region 产品分类 /// /// ProductCategoryEntity → ProductCategoryDto @@ -935,5 +1030,217 @@ namespace Model.Mapper }; } #endregion + + #region 用户 + public static UserDto ToDto(this UserEntity entity) + { + if (entity == null) return null; + return new UserDto + { + Id = entity.Id.ToString(), + UserName = entity.UserName, + RealName = entity.RealName, + Email = entity.Email, + Phone = entity.Phone, + OrgId = entity.OrgId.ToString(), + Position = entity.Position, + SkillTags = entity.SkillTags, + IsEnabled = entity.IsEnabled, + LastLoginTime = entity.LastLoginTime, + Avatar = entity.Avatar, + Remark = entity.Remark, + CreateTime = entity.CreateTime + }; + } + public static List ToDtoList(this List entities) + => entities?.Select(e => e.ToDto()).ToList() ?? new List(); + public static UserEntity ToEntity(this UserDto dto) + { + if (dto == null) return null; + return new UserEntity + { + Id = ParseId(dto.Id), + UserName = dto.UserName, + RealName = dto.RealName, + Email = dto.Email, + Phone = dto.Phone, + OrgId = ParseId(dto.OrgId), + Position = dto.Position, + SkillTags = dto.SkillTags, + IsEnabled = dto.IsEnabled, + Avatar = dto.Avatar, + Remark = dto.Remark + }; + } + public static UserOptionDto ToOptionDto(this UserEntity entity) + { + if (entity == null) return null; + return new UserOptionDto { Id = entity.Id.ToString(), UserName = entity.UserName, RealName = entity.RealName }; + } + public static List ToOptionDtoList(this List entities) + => entities?.Select(e => e.ToOptionDto()).ToList() ?? new List(); + #endregion + + #region 角色 + public static RoleDto ToDto(this RoleEntity entity) + { + if (entity == null) return null; + return new RoleDto + { + Id = entity.Id.ToString(), + Code = entity.Code, + Name = entity.Name, + DataScope = entity.DataScope, + IsSystem = entity.IsSystem, + Sort = entity.Sort, + Remark = entity.Remark, + CreateTime = entity.CreateTime + }; + } + public static List ToDtoList(this List entities) + => entities?.Select(e => e.ToDto()).ToList() ?? new List(); + public static RoleEntity ToEntity(this RoleDto dto) + { + if (dto == null) return null; + return new RoleEntity + { + Id = ParseId(dto.Id), + Code = dto.Code, + Name = dto.Name, + DataScope = dto.DataScope, + IsSystem = dto.IsSystem, + Sort = dto.Sort, + Remark = dto.Remark + }; + } + public static RoleOptionDto ToOptionDto(this RoleEntity entity) + { + if (entity == null) return null; + return new RoleOptionDto { Id = entity.Id.ToString(), Code = entity.Code, Name = entity.Name }; + } + public static List ToOptionDtoList(this List entities) + => entities?.Select(e => e.ToOptionDto()).ToList() ?? new List(); + #endregion + + #region 权限 + public static PermissionDto ToDto(this PermissionEntity entity) + { + if (entity == null) return null; + return new PermissionDto + { + Id = entity.Id.ToString(), + Code = entity.Code, + Name = entity.Name, + Group = entity.Group, + IsSystem = entity.IsSystem, + Sort = entity.Sort + }; + } + public static List ToDtoList(this List entities) + => entities?.Select(e => e.ToDto()).ToList() ?? new List(); + public static PermissionEntity ToEntity(this PermissionDto dto) + { + if (dto == null) return null; + return new PermissionEntity + { + Id = ParseId(dto.Id), + Code = dto.Code, + Name = dto.Name, + Group = dto.Group, + IsSystem = dto.IsSystem, + Sort = dto.Sort + }; + } + #endregion + + #region 组织架构 + public static OrgTreeDto ToTreeDto(this OrgEntity entity) + { + if (entity == null) return null; + return new OrgTreeDto + { + Id = entity.Id.ToString(), + ParentId = entity.ParentId.ToString(), + Name = entity.Name, + Code = entity.Code, + OrgType = entity.OrgType, + ShiftType = entity.ShiftType, + Leader = entity.Leader, + Phone = entity.Phone, + Sort = entity.Sort, + Remark = entity.Remark + }; + } + public static OrgDto ToDto(this OrgEntity entity) + { + if (entity == null) return null; + return new OrgDto + { + Id = entity.Id.ToString(), + ParentId = entity.ParentId.ToString(), + Name = entity.Name, + Code = entity.Code, + OrgType = entity.OrgType, + ShiftType = entity.ShiftType, + Leader = entity.Leader, + Phone = entity.Phone, + Sort = entity.Sort, + Remark = entity.Remark + }; + } + public static List ToDtoList(this List entities) + => entities?.Select(e => e.ToDto()).ToList() ?? new List(); + public static OrgEntity ToEntity(this OrgDto dto) + { + if (dto == null) return null; + return new OrgEntity + { + Id = ParseId(dto.Id), + ParentId = ParseId(dto.ParentId), + Name = dto.Name, + Code = dto.Code, + OrgType = (byte)dto.OrgType, + ShiftType = (byte)dto.ShiftType, + Leader = dto.Leader, + Phone = dto.Phone, + Sort = dto.Sort, + Remark = dto.Remark + }; + } + public static OrgOptionDto ToOptionDto(this OrgEntity entity) + { + if (entity == null) return null; + return new OrgOptionDto { Id = entity.Id.ToString(), Name = entity.Name, OrgType = entity.OrgType }; + } + public static List ToOptionDtoList(this List entities) + => entities?.Select(e => e.ToOptionDto()).ToList() ?? new List(); + #endregion + + #region 审计日志 + public static AuditLogDto ToDto(this AuditLogEntity entity) + { + if (entity == null) return null; + return new AuditLogDto + { + Id = entity.Id.ToString(), + UserId = entity.UserId.ToString(), + UserName = entity.UserName, + RoleId = entity.RoleId.ToString(), + RoleName = entity.RoleName, + OperationType = entity.OperationType, + OperationTarget = entity.OperationTarget, + TargetId = entity.TargetId.ToString(), + DeviceId = entity.DeviceId.ToString(), + OldValue = entity.OldValue, + NewValue = entity.NewValue, + Ip = entity.Ip, + Description = entity.Description, + OperateTime = entity.OperateTime, + CreateTime = entity.CreateTime + }; + } + public static List ToDtoList(this List entities) + => entities?.Select(e => e.ToDto()).ToList() ?? new List(); + #endregion } } diff --git a/Service/Implement/Config/DeviceCommandService.cs b/Service/Implement/Config/DeviceCommandService.cs index 2ee1904..db325d0 100644 --- a/Service/Implement/Config/DeviceCommandService.cs +++ b/Service/Implement/Config/DeviceCommandService.cs @@ -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 /// /// 设备指令下发 服务实现 /// 目前无网关运行时/采集引擎:每次调用先落一条设备日志(始终可验证), - /// 设备在线时才实际尝试 Modbus 写(GatewayCode 需为 "ip:port",默认 127.0.0.1:502)。 + /// 设备在线时才实际尝试 Modbus 写(连接参数从网关表按 GatewayId 加载)。 + /// 每次下发都写审计日志(操作人/角色/时间/前后值),满足实验室审计要求。 /// public class DeviceCommandService : IDeviceCommandService { + private readonly IAuditRecorder _audit; + + public DeviceCommandService(IAuditRecorder audit) + { + _audit = audit; + } + public async Task 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() + .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) }; } - /// - /// 解析 GatewayCode 为 host:port;非法/缺失回退 127.0.0.1:502 - /// - 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); - } - /// /// 写一条设备日志(设备独享日志,前端设备日志抽屉可见) /// diff --git a/Service/Implement/Config/DeviceService.cs b/Service/Implement/Config/DeviceService.cs index f90c9bd..ce9b2a2 100644 --- a/Service/Implement/Config/DeviceService.cs +++ b/Service/Implement/Config/DeviceService.cs @@ -3,11 +3,13 @@ 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 @@ -17,10 +19,17 @@ namespace Service.Implement.Config /// public class DeviceService : IDeviceService { + private readonly IAuditRecorder _audit; + + public DeviceService(IAuditRecorder audit) + { + _audit = audit; + } + /// /// 分页查询设备列表(支持关键字搜索编号/名称/类型,可按所属产品筛选) /// - public async Task>> GetPagedAsync(int pageIndex, int pageSize, RefAsync total, string? keyword, long? productId = null) + public async Task>> GetPagedAsync(int pageIndex, int pageSize, RefAsync total, string? keyword, long? productId = null, long? gatewayId = null) { try { @@ -29,11 +38,13 @@ namespace Service.Implement.Config .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>.Success(dtos); } catch (Exception ex) @@ -57,6 +68,7 @@ namespace Service.Implement.Config var dto = entity.ToDto(); await FillProductNames(new List { dto }); + await FillGatewayNames(new List { dto }); return Result.Success(dto); } catch (Exception ex) @@ -87,6 +99,10 @@ namespace Service.Implement.Config // 初始离线 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) @@ -112,9 +128,18 @@ namespace Service.Implement.Config if (exists) return Result.Error($"设备编号【{entity.Code}】已存在"); + // 操作前值(审计):先查旧记录 + var oldEntity = await SqlSugarContext.DbContext.Queryable() + .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) @@ -133,10 +158,17 @@ namespace Service.Implement.Config try { + var oldEntity = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == id && x.IsDel == 0).FirstAsync(); + await SqlSugarContext.DbContext.Updateable() .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) @@ -165,6 +197,31 @@ namespace Service.Implement.Config } } + /// + /// 给设备 DTO 填充所属网关名称(GatewayName 展示用) + /// + private async Task FillGatewayNames(List 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() + .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; + } + } + /// /// 给设备 DTO 填充所属产品的型号/名称(ProductName 展示用) /// diff --git a/Service/Implement/Config/GatewayService.cs b/Service/Implement/Config/GatewayService.cs index 781e7ec..1a339d0 100644 --- a/Service/Implement/Config/GatewayService.cs +++ b/Service/Implement/Config/GatewayService.cs @@ -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 { /// /// 网关管理 服务实现 /// public class GatewayService : IGatewayService { - // TODO: 实现 网关管理 相关方法 + public async Task>> GetPagedAsync(int pageIndex, int pageSize, RefAsync total, string? keyword = null, int? protocolType = null) + { + try + { + var list = await SqlSugarContext.DbContext.Queryable() + .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>.Success(list.ToDtoList()); + } + catch (Exception ex) + { + return Result>.Error("查询网关列表失败", ex); + } + } + + public async Task> GetByIdAsync(long id) + { + try + { + var entity = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == id && x.IsDel == 0).FirstAsync(); + if (entity == null) return Result.Error("网关不存在"); + return Result.Success(entity.ToDto()); + } + catch (Exception ex) + { + return Result.Error("查询网关详情失败", ex); + } + } + + public async Task> AddAsync(GatewayDto dto) + { + try + { + // 编码唯一校验 + var exists = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Code == dto.Code && x.IsDel == 0).AnyAsync(); + if (exists) return Result.Error($"网关编码「{dto.Code}」已存在"); + + var entity = dto.ToEntity(); + entity.Id = 0; // 雪花ID自动生成 + var id = await SqlSugarContext.DbContext.Insertable(entity).ExecuteReturnSnowflakeIdAsync(); + entity.Id = id; + return Result.Success(entity.ToDto()); + } + catch (Exception ex) + { + return Result.Error("新增网关失败", ex); + } + } + + public async Task> UpdateAsync(GatewayDto dto) + { + try + { + if (!long.TryParse(dto.Id, out var id) || id <= 0) + return Result.Error("网关Id无效"); + + var entity = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == id && x.IsDel == 0).FirstAsync(); + if (entity == null) return Result.Error("网关不存在"); + + // 编码唯一校验(排除自身) + var codeExists = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Code == dto.Code && x.Id != id && x.IsDel == 0).AnyAsync(); + if (codeExists) return Result.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.Success(newEntity.ToDto()); + } + catch (Exception ex) + { + return Result.Error("修改网关失败", ex); + } + } + + public async Task DeleteAsync(long id) + { + try + { + var entity = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == id && x.IsDel == 0).FirstAsync(); + if (entity == null) return Result.Error("网关不存在"); + + // 软删除网关 + await SqlSugarContext.DbContext.Updateable() + .SetColumns(x => x.IsDel == 1) + .Where(x => x.Id == id).ExecuteCommandAsync(); + + // 该网关下设备的 GatewayId 置 0(不级联删除设备) + await SqlSugarContext.DbContext.Updateable() + .SetColumns(x => x.GatewayId == 0) + .Where(x => x.GatewayId == id).ExecuteCommandAsync(); + + return Result.Success(); + } + catch (Exception ex) + { + return Result.Error("删除网关失败", ex); + } + } + + /// + /// 测试网关连接:TCP 尝试 TcpClient.ConnectAsync 3s 超时,串口尝试 SerialPort.Open, + /// 成功则 OnlineStatus=Online+更新 LastConnectedTime,失败则 OnlineStatus=Fault+记录 LastError + /// + public async Task> TestConnectionAsync(long id) + { + try + { + var entity = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == id && x.IsDel == 0).FirstAsync(); + if (entity == null) return Result.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() + .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.Success(new GatewayTestResultDto + { + Success = success, + Message = success ? $"连接成功({entity.Host}:{entity.Port ?? 0})" : message + }); + } + catch (Exception ex) + { + return Result.Error("测试连接失败", ex); + } + } + + public async Task>> GetOptionsAsync() + { + try + { + var list = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0 && x.IsEnabled) + .OrderBy(x => x.Code) + .ToListAsync(); + return Result>.Success(list.ToOptionDtoList()); + } + catch (Exception ex) + { + return Result>.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 } } diff --git a/Service/Implement/System/AuditLogService.cs b/Service/Implement/System/AuditLogService.cs new file mode 100644 index 0000000..6e1df76 --- /dev/null +++ b/Service/Implement/System/AuditLogService.cs @@ -0,0 +1,45 @@ +using Model; +using Model.Dto.System; +using Model.Entity.System; +using Model.Mapper; +using ORM; +using Service.Interface; +using SqlSugar; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Service.Implement +{ + /// + /// 审计日志 服务实现(查询端;写入端在各业务服务中直接插 AuditLogEntity,或经 AuditHelper) + /// + public class AuditLogService : IAuditLogService + { + public async Task>> GetPagedAsync(int pageIndex, int pageSize, RefAsync total, + string? keyword = null, string? operationType = null, string? operationTarget = null, + DateTime? startTime = null, DateTime? endTime = null) + { + try + { + var list = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0) + .WhereIF(!string.IsNullOrWhiteSpace(keyword), + x => (x.UserName != null && x.UserName.Contains(keyword!)) + || (x.Description != null && x.Description.Contains(keyword!)) + || (x.RoleName != null && x.RoleName.Contains(keyword!))) + .WhereIF(!string.IsNullOrWhiteSpace(operationType), x => x.OperationType == operationType) + .WhereIF(!string.IsNullOrWhiteSpace(operationTarget), x => x.OperationTarget == operationTarget) + .WhereIF(startTime.HasValue, x => x.OperateTime >= startTime!.Value) + .WhereIF(endTime.HasValue, x => x.OperateTime <= endTime!.Value) + .OrderBy(x => x.OperateTime, OrderByType.Desc) + .ToPageListAsync(pageIndex, pageSize, total); + return Result>.Success(list.ToDtoList()); + } + catch (Exception ex) + { + return Result>.Error("查询审计日志失败", ex); + } + } + } +} diff --git a/Service/Implement/System/AuditRecorder.cs b/Service/Implement/System/AuditRecorder.cs new file mode 100644 index 0000000..b3932a4 --- /dev/null +++ b/Service/Implement/System/AuditRecorder.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.Logging; +using Model.Entity.System; +using ORM; +using Service.Interface; +using SqlSugar; + +namespace Service.Implement +{ + /// + /// 审计日志记录器实现:操作人/角色自动取自 ICurrentUser(JWT Claims),失败不抛出(审计不应阻断业务) + /// + public class AuditRecorder : IAuditRecorder + { + private readonly ICurrentUser _currentUser; + private readonly ILogger _logger; + + public AuditRecorder(ICurrentUser currentUser, ILogger logger) + { + _currentUser = currentUser; + _logger = logger; + } + + public async Task RecordAsync(string operationType, string operationTarget, long targetId, + long deviceId = 0, string? oldValue = null, string? newValue = null, + string? ip = null, string? description = null) + { + try + { + var now = DateTime.Now; + var roleId = _currentUser.RoleIds.FirstOrDefault(); + var roleName = _currentUser.RoleNames.FirstOrDefault(); + await SqlSugarContext.DbContext.Insertable(new AuditLogEntity + { + UserId = _currentUser.UserId, + UserName = _currentUser.UserName, + RoleId = roleId, + RoleName = roleName ?? "", + OperationType = operationType, + OperationTarget = operationTarget, + TargetId = targetId, + DeviceId = deviceId, + OldValue = oldValue, + NewValue = newValue, + Ip = ip, + Description = description, + OperateTime = now, + CreateTime = now + }).ExecuteCommandAsync(); + } + catch (Exception ex) + { + // 审计失败只记日志,不阻断业务 + _logger.LogError(ex, "写审计日志失败({OperationType} {OperationTarget} {TargetId})", + operationType, operationTarget, targetId); + } + } + } +} diff --git a/Service/Implement/System/AuthService.cs b/Service/Implement/System/AuthService.cs new file mode 100644 index 0000000..435141f --- /dev/null +++ b/Service/Implement/System/AuthService.cs @@ -0,0 +1,252 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; +using Model; +using Model.Dto.System; +using Model.Entity.System; +using Model.Mapper; +using ORM; +using Service.Interface; +using SqlSugar; + +namespace Service.Implement +{ + /// + /// 认证授权 服务实现:登录校验 → 加载角色/权限 → 签发 JWT(权限编码写入 Claims) + /// + public class AuthService : IAuthService + { + private readonly IConfiguration _configuration; + private readonly ICurrentUser _currentUser; + private readonly ILogger _logger; + + public AuthService(IConfiguration configuration, ICurrentUser currentUser, ILogger logger) + { + _configuration = configuration; + _currentUser = currentUser; + _logger = logger; + } + + public async Task> LoginAsync(LoginDto dto, string? ip) + { + if (dto == null || string.IsNullOrWhiteSpace(dto.UserName) || string.IsNullOrWhiteSpace(dto.Password)) + return Result.Error("用户名和密码不能为空"); + + try + { + var user = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.UserName == dto.UserName && x.IsDel == 0).FirstAsync(); + if (user == null) + return Result.Error("用户名或密码错误"); + if (user.IsEnabled == 0) + return Result.Error("账号已被禁用,请联系管理员"); + if (!PasswordHelper.Verify(dto.Password, user.PasswordHash)) + return Result.Error("用户名或密码错误"); + + // 加载角色与权限 + var (roleIds, roleNames, permissionCodes, dataScope) = await LoadUserAuthAsync(user.Id); + + // 签发 JWT + var token = GenerateJwt(user, roleIds, roleNames, permissionCodes, dataScope); + + // 更新最后登录时间 + await SqlSugarContext.DbContext.Updateable() + .SetColumns(x => x.LastLoginTime == DateTime.Now) + .Where(x => x.Id == user.Id).ExecuteCommandAsync(); + + // 登录审计 + await WriteAuditAsync(user.Id, user.UserName, roleIds.FirstOrDefault(), roleNames.FirstOrDefault(), + "Login", "User", user.Id, 0, null, null, ip, $"用户 {user.UserName} 登录系统"); + + var userDto = user.ToDto(); + userDto.RoleIds = roleIds.Select(r => r.ToString()).ToList(); + userDto.RoleNames = string.Join(",", roleNames); + return Result.Success(new LoginResultDto + { + Token = token, + RefreshToken = token, + User = userDto, + Permissions = permissionCodes + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "登录异常"); + return Result.Error("登录失败", ex); + } + } + + public async Task LogoutAsync() + { + if (!_currentUser.IsAuthenticated) + return Result.Success(); + try + { + await WriteAuditAsync(_currentUser.UserId, _currentUser.UserName, + _currentUser.RoleIds.FirstOrDefault(), _currentUser.RoleNames.FirstOrDefault(), + "Logout", "User", _currentUser.UserId, 0, null, null, null, + $"用户 {_currentUser.UserName} 退出登录"); + return Result.Success(); + } + catch (Exception ex) + { + _logger.LogError(ex, "退出登录记录审计失败"); + return Result.Success(); // 退出本身不应失败 + } + } + + public async Task> GetMeAsync() + { + if (!_currentUser.IsAuthenticated) + return Result.Error("未登录或 Token 已失效"); + + try + { + var user = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == _currentUser.UserId && x.IsDel == 0).FirstAsync(); + if (user == null) + return Result.Error("用户不存在或已被删除"); + + var userDto = user.ToDto(); + userDto.RoleIds = _currentUser.RoleIds.Select(r => r.ToString()).ToList(); + userDto.RoleNames = string.Join(",", _currentUser.RoleNames); + return Result.Success(new LoginResultDto + { + Token = "", + RefreshToken = "", + User = userDto, + Permissions = _currentUser.Permissions + }); + } + catch (Exception ex) + { + return Result.Error("获取当前用户信息失败", ex); + } + } + + public async Task ChangePasswordAsync(ChangePasswordDto dto) + { + if (dto == null || string.IsNullOrWhiteSpace(dto.OldPassword) || string.IsNullOrWhiteSpace(dto.NewPassword)) + return Result.Error("原密码和新密码不能为空"); + if (dto.NewPassword.Length < 6) + return Result.Error("新密码长度不能少于 6 位"); + if (!_currentUser.IsAuthenticated) + return Result.Error("未登录或 Token 已失效"); + + try + { + var user = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == _currentUser.UserId && x.IsDel == 0).FirstAsync(); + if (user == null) + return Result.Error("用户不存在或已被删除"); + if (!PasswordHelper.Verify(dto.OldPassword, user.PasswordHash)) + return Result.Error("原密码错误"); + + await SqlSugarContext.DbContext.Updateable() + .SetColumns(x => x.PasswordHash == PasswordHelper.Hash(dto.NewPassword)) + .Where(x => x.Id == user.Id).ExecuteCommandAsync(); + + await WriteAuditAsync(user.Id, user.UserName, _currentUser.RoleIds.FirstOrDefault(), _currentUser.RoleNames.FirstOrDefault(), + "Update", "User", user.Id, 0, null, null, null, $"用户 {user.UserName} 修改了自己的密码"); + return Result.Success(); + } + catch (Exception ex) + { + return Result.Error("修改密码失败", ex); + } + } + + /// + /// 加载用户的角色Id/角色名/权限编码/数据范围(取所有角色的并集,数据范围取最宽) + /// + public static async Task<(List roleIds, List roleNames, List permissions, byte dataScope)> + LoadUserAuthAsync(long userId) + { + var db = SqlSugarContext.DbContext; + var roleIds = await db.Queryable() + .Where(x => x.UserId == userId) + .Select(x => x.RoleId) + .ToListAsync(); + + var roleList = new List(); + if (roleIds.Count > 0) + roleList = await db.Queryable() + .Where(x => roleIds.Contains(x.Id) && x.IsDel == 0).ToListAsync(); + + var permissionCodes = new List(); + if (roleIds.Count > 0) + { + var permIds = await db.Queryable() + .Where(x => roleIds.Contains(x.RoleId)) + .Select(x => x.PermissionId) + .ToListAsync(); + if (permIds.Count > 0) + { + var perms = await db.Queryable() + .Where(x => permIds.Contains(x.Id) && x.IsDel == 0).ToListAsync(); + permissionCodes = perms.Select(p => p.Code).Distinct().ToList(); + } + } + + // 数据范围取最宽:任一角色是"全部"即为全部 + var dataScope = roleList.Count > 0 ? roleList.Min(r => r.DataScope) : (byte)2; + return (roleIds, roleList.Select(r => r.Name).ToList(), permissionCodes, dataScope); + } + + /// 生成 JWT:把用户身份 + 角色 + 权限编码全部写入 Claims(授权过滤器免查库) + private string GenerateJwt(UserEntity user, List roleIds, List roleNames, List permissions, byte dataScope) + { + var jwt = _configuration.GetSection("Jwt"); + var secret = jwt["SecretKey"]!; + var issuer = jwt["Issuer"] ?? "IOT-API"; + var audience = jwt["Audience"] ?? "IOT-Web"; + var expireMinutes = double.TryParse(jwt["ExpireMinutes"], out var m) ? m : 480; + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(ClaimTypes.Name, user.UserName), + new("roleIds", string.Join(",", roleIds)), + new("roleNames", string.Join(",", roleNames)), + new("permissions", string.Join(",", permissions)), + new("dataScope", dataScope.ToString()), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")) + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var token = new JwtSecurityToken(issuer, audience, claims, + expires: DateTime.Now.AddMinutes(expireMinutes), signingCredentials: creds); + return new JwtSecurityTokenHandler().WriteToken(token); + } + + /// 写一条审计日志 + private static async Task WriteAuditAsync(long userId, string? userName, long? roleId, string? roleName, + string operationType, string operationTarget, long targetId, long deviceId, + string? oldValue, string? newValue, string? ip, string? description) + { + var now = DateTime.Now; + await SqlSugarContext.DbContext.Insertable(new AuditLogEntity + { + UserId = userId, + UserName = userName ?? "", + RoleId = roleId ?? 0, + RoleName = roleName ?? "", + OperationType = operationType, + OperationTarget = operationTarget, + TargetId = targetId, + DeviceId = deviceId, + OldValue = oldValue, + NewValue = newValue, + Ip = ip, + Description = description, + OperateTime = now, + CreateTime = now + }).ExecuteCommandAsync(); + } + } +} diff --git a/Service/Implement/System/DataSeeder.cs b/Service/Implement/System/DataSeeder.cs new file mode 100644 index 0000000..05b193e --- /dev/null +++ b/Service/Implement/System/DataSeeder.cs @@ -0,0 +1,124 @@ +using Model.Entity.System; +using ORM; + +namespace Service.Implement +{ + /// + /// RBAC 数据种子:预定义 4 个系统角色 + 6 个权限 + 默认管理员账号(admin/admin123) + /// 幂等:按 Code 查存在性,存在则跳过;可重复执行 + /// + public static class DataSeeder + { + /// 数据范围:1=全部, 2=本实验室 + public const byte DataScopeAll = 1; + public const byte DataScopeLab = 2; + + /// 6 个系统权限编码 + public static readonly (string Code, string Name, string Group)[] Permissions = + { + ("device:view", "查看设备", "device"), + ("device:edit", "编辑设备", "device"), + ("device:control", "控制设备", "device"), + ("alert:confirm", "确认告警", "alert"), + ("inspection:manage", "巡检管理", "inspection"), + ("user:manage", "用户管理", "user") + }; + + /// + /// 4 个预定义角色:超级管理员(全部) / 实验室管理员(本实验室) / 设备操作员(本实验室) / 维修工程师(本实验室) + /// 权限矩阵严格按需求文档 + /// + public static readonly (string Code, string Name, byte DataScope, string[] Permissions)[] Roles = + { + ("superadmin", "超级管理员", DataScopeAll, new[] { "device:view", "device:edit", "device:control", "alert:confirm", "inspection:manage", "user:manage" }), + ("labadmin", "实验室管理员", DataScopeLab, new[] { "device:view", "device:edit", "device:control", "alert:confirm", "inspection:manage" }), + ("operator", "设备操作员", DataScopeLab, new[] { "device:view", "inspection:manage" }), + ("maintengineer", "维修工程师", DataScopeLab, new[] { "device:view", "device:edit", "device:control", "alert:confirm" }) + }; + + public static void Seed() + { + try + { + var db = SqlSugarContext.DbContext; + var now = DateTime.Now; + + // 1. 权限种子 + var permIdByCode = new Dictionary(); + foreach (var (code, name, group) in Permissions) + { + var existing = db.Queryable().Where(x => x.Code == code).First(); + if (existing != null) { permIdByCode[code] = existing.Id; continue; } + var perm = new PermissionEntity + { + Code = code, Name = name, Group = group, IsSystem = 1, + Sort = Array.FindIndex(Permissions, p => p.Code == code), + CreateTime = now + }; + var id = db.Insertable(perm).ExecuteReturnSnowflakeId(); + permIdByCode[code] = id; + } + + // 2. 角色种子 + 角色-权限关联 + foreach (var (code, name, scope, perms) in Roles) + { + var role = db.Queryable().Where(x => x.Code == code).First(); + long roleId; + if (role == null) + { + role = new RoleEntity + { + Code = code, Name = name, DataScope = scope, IsSystem = 1, + Sort = Array.FindIndex(Roles, r => r.Code == code), + Remark = "系统预定义角色", + CreateTime = now + }; + roleId = db.Insertable(role).ExecuteReturnSnowflakeId(); + } + else + { + roleId = role.Id; + } + + // 补齐角色-权限关联(只加不删,避免覆盖管理员自定义调整) + foreach (var permCode in perms) + { + if (!permIdByCode.TryGetValue(permCode, out var permId)) continue; + var linkExists = db.Queryable() + .Where(x => x.RoleId == roleId && x.PermissionId == permId).Any(); + if (!linkExists) + { + db.Insertable(new RolePermissionEntity { RoleId = roleId, PermissionId = permId, CreateTime = now }).ExecuteCommand(); + } + } + } + + // 3. 默认管理员账号(admin/admin123,挂在超级管理员角色) + var adminExists = db.Queryable().Where(x => x.UserName == "admin" && x.IsDel == 0).Any(); + if (!adminExists) + { + var superAdmin = db.Queryable().Where(x => x.Code == "superadmin").First(); + var admin = new UserEntity + { + UserName = "admin", + PasswordHash = PasswordHelper.Hash("admin123"), + RealName = "系统管理员", + IsEnabled = 1, + Remark = "系统默认管理员(首次登录后请修改密码)", + CreateTime = now + }; + var adminId = db.Insertable(admin).ExecuteReturnSnowflakeId(); + if (superAdmin != null) + { + db.Insertable(new UserRoleEntity { UserId = adminId, RoleId = superAdmin.Id, CreateTime = now }).ExecuteCommand(); + } + } + } + catch (Exception) + { + // 种子失败不阻断启动(例如表刚建好并发场景),下次启动会重试 + throw; + } + } + } +} diff --git a/Service/Implement/System/OrgService.cs b/Service/Implement/System/OrgService.cs new file mode 100644 index 0000000..d8c6dbe --- /dev/null +++ b/Service/Implement/System/OrgService.cs @@ -0,0 +1,332 @@ +using Model; +using Model.Dto.System; +using Model.Entity.System; +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 +{ + /// + /// 组织架构管理 服务实现 + /// + public class OrgService : IOrgService + { + private readonly IAuditRecorder _audit; + + public OrgService(IAuditRecorder audit) + { + _audit = audit; + } + + public async Task>> GetTreeAsync() + { + try + { + var db = SqlSugarContext.DbContext; + var all = await db.Queryable() + .Where(x => x.IsDel == 0) + .OrderBy(x => x.Sort).OrderBy(x => x.Code) + .ToListAsync(); + + // 聚合每个组织下的用户角色 + 数据范围(ScopeSummary/用户数展示用) + var users = await db.Queryable() + .Where(x => x.IsDel == 0) + .Select(x => new { x.Id, x.OrgId }) + .ToListAsync(); + var userIds = users.Select(u => u.Id).ToList(); + var roleLinks = userIds.Count > 0 + ? await db.Queryable().Where(x => userIds.Contains(x.UserId)).ToListAsync() + : new List(); + var roleIds = roleLinks.Select(l => l.RoleId).Distinct().ToList(); + var roles = roleIds.Count > 0 + ? await db.Queryable().Where(x => roleIds.Contains(x.Id) && x.IsDel == 0).ToListAsync() + : new List(); + var roleMap = roles.ToDictionary(r => r.Id, r => r); + + // userId -> 角色数据范围标签列表(如「超级管理员·全部」) + var userScopeTags = new Dictionary>(); + foreach (var link in roleLinks) + { + if (!roleMap.TryGetValue(link.RoleId, out var role)) continue; + var tag = $"{role.Name}·{ScopeLabel(role.DataScope)}"; + if (!userScopeTags.TryGetValue(link.UserId, out var list)) + userScopeTags[link.UserId] = list = new List(); + if (!list.Contains(tag)) list.Add(tag); + } + + // orgId -> 直接挂靠用户Id列表 + var orgUsers = users.GroupBy(u => u.OrgId).ToDictionary(g => g.Key, g => g.Select(u => u.Id).ToList()); + + var tree = BuildTreeWithStats(all, 0, orgUsers, userScopeTags); + return Result>.Success(tree); + } + catch (Exception ex) + { + return Result>.Error("查询组织树失败", ex); + } + } + + /// + /// 建树并聚合统计:用户数(含下级)+ 数据权限摘要(子树内用户角色的数据范围并集) + /// + private static List BuildTreeWithStats(List all, long parentId, + Dictionary> orgUsers, Dictionary> userScopeTags) + { + var nodes = new List(); + foreach (var entity in all.Where(x => x.ParentId == parentId).OrderBy(x => x.Sort).ThenBy(x => x.Code)) + { + var node = entity.ToTreeDto(); + node.Children = BuildTreeWithStats(all, entity.Id, orgUsers, userScopeTags); + + // 本节点直接用户 + orgUsers.TryGetValue(entity.Id, out var directUserIds); + node.UserCount = directUserIds?.Count ?? 0; + + // 汇总子树:用户总数 + 数据权限标签并集 + var scopes = new List(); + if (directUserIds != null) + { + foreach (var uid in directUserIds) + if (userScopeTags.TryGetValue(uid, out var tags)) + foreach (var t in tags) + if (!scopes.Contains(t)) scopes.Add(t); + } + var total = node.UserCount; + foreach (var child in node.Children) + { + total += child.TotalUserCount; + foreach (var t in child.ScopeSummary) + if (!scopes.Contains(t)) scopes.Add(t); + } + node.TotalUserCount = total; + node.ScopeSummary = scopes; + + nodes.Add(node); + } + return nodes; + } + + /// 数据范围标签:1=全部, 2=本组织及下级 + private static string ScopeLabel(byte dataScope) => dataScope == 1 ? "全部" : "本组织及下级"; + + public async Task> AddAsync(OrgDto dto) + { + if (dto == null || string.IsNullOrWhiteSpace(dto.Name) || string.IsNullOrWhiteSpace(dto.Code)) + return Result.Error("组织名称和编码不能为空"); + + try + { + var exists = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Code == dto.Code && x.IsDel == 0).AnyAsync(); + if (exists) return Result.Error($"组织编码「{dto.Code}」已存在"); + + // 父节点校验 + 层级固定校验(根节点必须是公司) + var parentId = ParseParent(dto.ParentId); + if (parentId > 0) + { + var parent = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == parentId && x.IsDel == 0).FirstAsync(); + if (parent == null) return Result.Error("父级组织不存在"); + var hierarchyError = ValidateHierarchy(parent.OrgType, (byte)dto.OrgType); + if (hierarchyError != null) return Result.Error(hierarchyError.Msg); + } + else if (dto.OrgType != 1) + { + return Result.Error("根节点只能是公司"); + } + + // 班次仅班组有效 + var shiftType = dto.OrgType == 4 ? (byte)Math.Max(1, dto.ShiftType) : (byte)0; + + var entity = dto.ToEntity(); + entity.Id = 0; + entity.ParentId = parentId; + entity.ShiftType = shiftType; + entity.CreateTime = DateTime.Now; + var id = await SqlSugarContext.DbContext.Insertable(entity).ExecuteReturnSnowflakeIdAsync(); + entity.Id = id; + + await _audit.RecordAsync("Create", "Org", id, 0, + newValue: $"[{OrgTypeName(entity.OrgType)}] {entity.Code}({entity.Name})", + description: $"新增组织 {entity.Name}"); + return Result.Success(entity.ToDto()); + } + catch (Exception ex) + { + return Result.Error("新增组织失败", ex); + } + } + + public async Task> UpdateAsync(OrgDto dto) + { + if (dto == null || !long.TryParse(dto.Id, out var id) || id <= 0) + return Result.Error("组织Id无效"); + + try + { + var entity = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == id && x.IsDel == 0).FirstAsync(); + if (entity == null) return Result.Error("组织不存在或已被删除"); + + var codeExists = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Code == dto.Code && x.Id != id && x.IsDel == 0).AnyAsync(); + if (codeExists) return Result.Error($"组织编码「{dto.Code}」已存在"); + + var parentId = ParseParent(dto.ParentId); + if (parentId == id) + return Result.Error("父级不能是自己"); + + // 层级固定:组织类型创建后不可变更 + if (dto.OrgType != entity.OrgType) + return Result.Error($"组织层级固定,类型不可变更(当前为「{OrgTypeName(entity.OrgType)}」)"); + + // 换父级时不能挂到自己的子孙下面(否则成环),且层级必须匹配 + if (parentId != entity.ParentId && parentId > 0) + { + var all = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0).ToListAsync(); + var subtreeIds = CollectSubtreeIds(all, id); + if (subtreeIds.Contains(parentId)) + return Result.Error("不能把组织挂到自己的下级下面"); + var newParent = all.FirstOrDefault(x => x.Id == parentId); + if (newParent == null) + return Result.Error("目标父级组织不存在"); + var hierarchyError = ValidateHierarchy(newParent.OrgType, entity.OrgType); + if (hierarchyError != null) return Result.Error(hierarchyError.Msg); + } + + var oldEntity = entity; + var newEntity = dto.ToEntity(); + newEntity.Id = id; + newEntity.ParentId = parentId; + newEntity.OrgType = entity.OrgType; // 类型不可变 + // 班次仅班组有效 + newEntity.ShiftType = entity.OrgType == 4 ? (byte)Math.Max(1, dto.ShiftType) : (byte)0; + await SqlSugarContext.DbContext.Updateable(newEntity) + .IgnoreColumns(x => new { x.CreateTime, x.IsDel }) + .ExecuteCommandAsync(); + + await _audit.RecordAsync("Update", "Org", id, 0, + oldValue: $"[{OrgTypeName(oldEntity.OrgType)}] {oldEntity.Name}", + newValue: $"[{OrgTypeName(newEntity.OrgType)}] {newEntity.Name}", + description: $"修改组织 {newEntity.Name}"); + return Result.Success(newEntity.ToDto()); + } + catch (Exception ex) + { + return Result.Error("修改组织失败", ex); + } + } + + public async Task DeleteAsync(long id) + { + if (id <= 0) return Result.Error("组织Id无效"); + try + { + var entity = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == id && x.IsDel == 0).FirstAsync(); + if (entity == null) return Result.Error("组织不存在或已被删除"); + + var hasChildren = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.ParentId == id && x.IsDel == 0).AnyAsync(); + if (hasChildren) + return Result.Error($"组织「{entity.Name}」下存在子组织,请先删除子组织"); + + var hasUsers = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.OrgId == id && x.IsDel == 0).AnyAsync(); + if (hasUsers) + return Result.Error($"组织「{entity.Name}」下仍有用户,请先移出用户"); + + await SqlSugarContext.DbContext.Updateable() + .SetColumns(x => x.IsDel == 1) + .Where(x => x.Id == id).ExecuteCommandAsync(); + + await _audit.RecordAsync("Delete", "Org", id, 0, + oldValue: $"[{OrgTypeName(entity.OrgType)}] {entity.Code}({entity.Name})", + description: $"删除组织 {entity.Name}"); + return Result.Success(); + } + catch (Exception ex) + { + return Result.Error("删除组织失败", ex); + } + } + + public async Task>> GetOptionsAsync() + { + try + { + var list = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0) + .OrderBy(x => x.Sort).OrderBy(x => x.Code) + .ToListAsync(); + return Result>.Success(list.ToOptionDtoList()); + } + catch (Exception ex) + { + return Result>.Error("查询组织选项失败", ex); + } + } + + public async Task> GetSubtreeIdsAsync(long orgId) + { + if (orgId <= 0) return new List(); + var all = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0).ToListAsync(); + return CollectSubtreeIds(all, orgId); + } + + #region 私有工具 + + /// 收集指定组织及其全部子孙 Id(含自身) + public static List CollectSubtreeIds(List all, long orgId) + { + var result = new List(); + var stack = new Stack(); + stack.Push(orgId); + while (stack.Count > 0) + { + var current = stack.Pop(); + result.Add(current); + foreach (var child in all.Where(x => x.ParentId == current)) + stack.Push(child.Id); + } + return result; + } + + private static long ParseParent(string? parentId) + => long.TryParse(parentId, out var v) ? v : 0; + + /// 层级固定校验:公司(1)→实验室(2)→部门(3)→班组(4),子级类型必须=父级类型+1 + private static Result? ValidateHierarchy(byte parentType, byte childType) + { + var validChild = parentType switch + { + 1 => (byte)2, // 公司下只能建实验室 + 2 => (byte)3, // 实验室下只能建部门 + 3 => (byte)4, // 部门下只能建班组 + _ => (byte)0 // 班组不能再有下级 + }; + if (childType != validChild) + return Result.Error($"组织层级固定:公司→实验室→部门→班组,该节点下只能建「{OrgTypeName(validChild)}」"); + return null; + } + + private static string OrgTypeName(byte orgType) => orgType switch + { + 1 => "公司", + 2 => "实验室", + 3 => "部门", + 4 => "班组", + _ => "组织" + }; + + #endregion + } +} diff --git a/Service/Implement/System/PasswordHelper.cs b/Service/Implement/System/PasswordHelper.cs new file mode 100644 index 0000000..a104683 --- /dev/null +++ b/Service/Implement/System/PasswordHelper.cs @@ -0,0 +1,49 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Service.Implement +{ + /// + /// 密码哈希工具(PBKDF2-SHA256,格式 salt$hash,盐随机 16 字节) + /// + public static class PasswordHelper + { + private const int Iterations = 10_000; + private const int SaltSize = 16; + private const int HashSize = 32; + + /// 生成密码哈希(salt$hash) + public static string Hash(string password) + { + var salt = RandomNumberGenerator.GetBytes(SaltSize); + var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, HashSize); + return $"{Convert.ToHexString(salt)}${Convert.ToHexString(hash)}"; + } + + /// 校验密码是否匹配 + public static bool Verify(string password, string? stored) + { + if (string.IsNullOrWhiteSpace(stored)) return false; + var parts = stored.Split('$'); + if (parts.Length != 2) return false; + var salt = Convert.FromHexString(parts[0]); + var expected = Convert.FromHexString(parts[1]); + var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, expected.Length); + return CryptographicOperations.FixedTimeEquals(actual, expected); + } + + /// 生成随机盐字符串(供外部使用) + public static string NewSalt() + { + var salt = RandomNumberGenerator.GetBytes(SaltSize); + return Convert.ToHexString(salt).ToLowerInvariant(); + } + + /// SHA256(供 RefreshToken 等场景) + public static string Sha256(string input) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + } +} diff --git a/Service/Implement/System/RoleService.cs b/Service/Implement/System/RoleService.cs index 9f91219..b4ea523 100644 --- a/Service/Implement/System/RoleService.cs +++ b/Service/Implement/System/RoleService.cs @@ -1,4 +1,14 @@ +using Model; +using Model.Dto.System; +using Model.Entity.System; +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 { @@ -7,6 +17,272 @@ namespace Service.Implement /// public class RoleService : IRoleService { - // TODO: 实现 角色权限管理 相关方法 + public async Task>> GetPagedAsync(int pageIndex, int pageSize, RefAsync total, string? keyword = null) + { + try + { + var list = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0) + .WhereIF(!string.IsNullOrWhiteSpace(keyword), x => x.Code.Contains(keyword!) || x.Name.Contains(keyword!)) + .OrderBy(x => x.Sort).OrderBy(x => x.CreateTime, OrderByType.Desc) + .ToPageListAsync(pageIndex, pageSize, total); + + var dtos = list.ToDtoList(); + await FillPermissionsAsync(dtos); + return Result>.Success(dtos); + } + catch (Exception ex) + { + return Result>.Error("查询角色列表失败", ex); + } + } + + public async Task> GetByIdAsync(long id) + { + try + { + var entity = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == id && x.IsDel == 0).FirstAsync(); + if (entity == null) return Result.Error("角色不存在或已被删除"); + var dto = entity.ToDto(); + await FillPermissionsAsync(new List { dto }); + return Result.Success(dto); + } + catch (Exception ex) + { + return Result.Error("查询角色详情失败", ex); + } + } + + public async Task> AddAsync(RoleDto dto) + { + if (dto == null || string.IsNullOrWhiteSpace(dto.Code) || string.IsNullOrWhiteSpace(dto.Name)) + return Result.Error("角色编码和名称不能为空"); + + try + { + var exists = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Code == dto.Code && x.IsDel == 0).AnyAsync(); + if (exists) return Result.Error($"角色编码「{dto.Code}」已存在"); + + var entity = dto.ToEntity(); + entity.Id = 0; + entity.IsSystem = 0; // 新建角色一律非系统内置 + entity.CreateTime = DateTime.Now; + var id = await SqlSugarContext.DbContext.Insertable(entity).ExecuteReturnSnowflakeIdAsync(); + entity.Id = id; + + if (dto.PermissionIds != null && dto.PermissionIds.Count > 0) + { + var permIds = dto.PermissionIds.Select(long.Parse).Where(p => p > 0).ToList(); + await ReplaceRolePermissionsAsync(id, permIds); + } + + var result = entity.ToDto(); + await FillPermissionsAsync(new List { result }); + return Result.Success(result); + } + catch (Exception ex) + { + return Result.Error("新增角色失败", ex); + } + } + + public async Task> UpdateAsync(RoleDto dto) + { + if (dto == null || !long.TryParse(dto.Id, out var id) || id <= 0) + return Result.Error("角色Id无效"); + + try + { + var entity = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == id && x.IsDel == 0).FirstAsync(); + if (entity == null) return Result.Error("角色不存在或已被删除"); + + // 系统内置角色不允许改编码 + if (entity.IsSystem == 1 && dto.Code != entity.Code) + return Result.Error($"系统内置角色「{entity.Name}」不允许修改编码"); + + var codeExists = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Code == dto.Code && x.Id != id && x.IsDel == 0).AnyAsync(); + if (codeExists) return Result.Error($"角色编码「{dto.Code}」已存在"); + + var newEntity = dto.ToEntity(); + newEntity.Id = id; + newEntity.IsSystem = entity.IsSystem; // 内置标记不可被前端覆盖 + await SqlSugarContext.DbContext.Updateable(newEntity) + .IgnoreColumns(x => new { x.CreateTime, x.IsDel, x.IsSystem }) + .ExecuteCommandAsync(); + + // 权限变化时同步更新关联(传入 null 表示不改权限) + if (dto.PermissionIds != null) + { + var permIds = dto.PermissionIds.Select(long.Parse).Where(p => p > 0).ToList(); + var guard = await EnsureAdminRemainsAfterRoleChangeAsync(id, permIds); + if (guard != null) return Result.Error(guard.Msg); + await ReplaceRolePermissionsAsync(id, permIds); + } + + var result = newEntity.ToDto(); + await FillPermissionsAsync(new List { result }); + return Result.Success(result); + } + catch (Exception ex) + { + return Result.Error("修改角色失败", ex); + } + } + + public async Task DeleteAsync(long id) + { + if (id <= 0) return Result.Error("角色Id无效"); + try + { + var entity = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == id && x.IsDel == 0).FirstAsync(); + if (entity == null) return Result.Error("角色不存在或已被删除"); + if (entity.IsSystem == 1) + return Result.Error($"系统内置角色「{entity.Name}」不允许删除"); + + var usedByUser = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.RoleId == id).AnyAsync(); + if (usedByUser) + return Result.Error("该角色下存在用户,请先移除角色下的用户再删除"); + + await SqlSugarContext.DbContext.Updateable() + .SetColumns(x => x.IsDel == 1) + .Where(x => x.Id == id).ExecuteCommandAsync(); + await SqlSugarContext.DbContext.Deleteable() + .Where(x => x.RoleId == id).ExecuteCommandAsync(); + return Result.Success(); + } + catch (Exception ex) + { + return Result.Error("删除角色失败", ex); + } + } + + public async Task AssignPermissionsAsync(long roleId, List permissionIds) + { + if (roleId <= 0) return Result.Error("角色Id无效"); + try + { + var exists = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == roleId && x.IsDel == 0).AnyAsync(); + if (!exists) return Result.Error("角色不存在或已被删除"); + + // 防锁死:移除角色的用户管理权限后,系统必须仍有其它可管理用户的管理员 + var newIds = permissionIds ?? new List(); + var guard = await EnsureAdminRemainsAfterRoleChangeAsync(roleId, newIds); + if (guard != null) return guard; + + await ReplaceRolePermissionsAsync(roleId, newIds); + return Result.Success(); + } + catch (Exception ex) + { + return Result.Error("分配权限失败", ex); + } + } + + public async Task>> GetOptionsAsync() + { + try + { + var list = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0) + .OrderBy(x => x.Sort).OrderBy(x => x.Code) + .ToListAsync(); + return Result>.Success(list.ToOptionDtoList()); + } + catch (Exception ex) + { + return Result>.Error("查询角色选项失败", ex); + } + } + + public async Task>> GetPermissionsAsync() + { + try + { + var list = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0) + .OrderBy(x => x.Sort).OrderBy(x => x.Code) + .ToListAsync(); + return Result>.Success(list.ToDtoList()); + } + catch (Exception ex) + { + return Result>.Error("查询权限列表失败", ex); + } + } + + /// 全量覆盖角色-权限关联 + private static async Task ReplaceRolePermissionsAsync(long roleId, List permissionIds) + { + var db = SqlSugarContext.DbContext; + await db.Deleteable().Where(x => x.RoleId == roleId).ExecuteCommandAsync(); + if (permissionIds.Count > 0) + { + var now = DateTime.Now; + var links = permissionIds.Distinct().Select(p => new RolePermissionEntity { RoleId = roleId, PermissionId = p, CreateTime = now }).ToList(); + await db.Insertable(links).ExecuteCommandAsync(); + } + } + + /// + /// 防锁死校验:当 roleId 将失去 user:manage 权限时,系统必须仍存在 + /// 「已启用且通过其它角色拥有 user:manage 权限」的用户,否则返回 Error + /// + private async Task EnsureAdminRemainsAfterRoleChangeAsync(long roleId, List newPermissionIds) + { + var db = SqlSugarContext.DbContext; + var managePermId = await db.Queryable() + .Where(x => x.Code == "user:manage" && x.IsDel == 0) + .Select(x => x.Id).FirstAsync(); + if (managePermId == 0) return null; + + bool currentlyHas = await db.Queryable() + .AnyAsync(x => x.RoleId == roleId && x.PermissionId == managePermId); + bool willHave = newPermissionIds.Contains(managePermId); + if (currentlyHas && !willHave) + { + var otherRoleIds = await db.Queryable() + .Where(x => x.PermissionId == managePermId && x.RoleId != roleId) + .Select(x => x.RoleId).ToListAsync(); + var otherUserIds = otherRoleIds.Count > 0 + ? await db.Queryable().Where(x => otherRoleIds.Contains(x.RoleId)).Select(x => x.UserId).ToListAsync() + : new List(); + var exists = otherUserIds.Count > 0 && await db.Queryable() + .AnyAsync(x => otherUserIds.Contains(x.Id) && x.IsDel == 0 && x.IsEnabled == 1); + if (!exists) + return Result.Error("操作被拒绝:移除该角色的用户管理权限后,系统将没有任何可管理用户的管理员"); + } + return null; + } + + /// 给角色 DTO 填充权限Id列表与权限编码列表 + private static async Task FillPermissionsAsync(List dtos) + { + if (dtos == null || dtos.Count == 0) return; + var db = SqlSugarContext.DbContext; + var roleIds = dtos.Select(d => long.Parse(d.Id)).ToList(); + + var links = await db.Queryable() + .Where(x => roleIds.Contains(x.RoleId)).ToListAsync(); + var permIds = links.Select(l => l.PermissionId).Distinct().ToList(); + var perms = permIds.Count > 0 + ? await db.Queryable().Where(x => permIds.Contains(x.Id) && x.IsDel == 0).ToListAsync() + : new List(); + var permMap = perms.ToDictionary(p => p.Id, p => p.Code); + + foreach (var d in dtos) + { + var rid = long.Parse(d.Id); + var myPermIds = links.Where(l => l.RoleId == rid).Select(l => l.PermissionId).ToList(); + d.PermissionIds = myPermIds.Select(p => p.ToString()).ToList(); + d.PermissionCodes = myPermIds.Where(p => permMap.ContainsKey(p)).Select(p => permMap[p]).ToList(); + } + } } } diff --git a/Service/Implement/System/UserService.cs b/Service/Implement/System/UserService.cs index e636da2..cffa8e4 100644 --- a/Service/Implement/System/UserService.cs +++ b/Service/Implement/System/UserService.cs @@ -1,12 +1,409 @@ +using Model; +using Model.Dto.System; +using Model.Entity.System; +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 { /// /// 用户管理 服务实现 + /// 数据权限:当前用户角色 DataScope=1 看全部;=2 只能看自己组织及下级组织下的用户 /// public class UserService : IUserService { - // TODO: 实现 用户管理 相关方法 + private readonly IOrgService _orgService; + private readonly ICurrentUser _currentUser; + + public UserService(IOrgService orgService, ICurrentUser currentUser) + { + _orgService = orgService; + _currentUser = currentUser; + } + + public async Task>> GetPagedAsync(int pageIndex, int pageSize, RefAsync total, string? keyword = null, long? roleId = null, long? orgId = null) + { + try + { + // 按角色筛选时先查出该角色下的用户Id + List? userIdsInRole = null; + if (roleId.HasValue && roleId.Value > 0) + { + userIdsInRole = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.RoleId == roleId!.Value) + .Select(x => x.UserId) + .ToListAsync(); + if (userIdsInRole.Count == 0) + return Result>.Success(new List()); + } + + // 数据范围过滤:DataScope=2 时只允许看本组织及下级的用户 + List? allowedOrgIds = null; + if (_currentUser.IsAuthenticated && _currentUser.DataScope != 1) + { + var me = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == _currentUser.UserId).FirstAsync(); + if (me != null && me.OrgId > 0) + allowedOrgIds = await _orgService.GetSubtreeIdsAsync(me.OrgId); + else + return Result>.Success(new List()); // 无组织归属则看不到任何用户 + } + + // 前端按组织节点筛选时叠加(同时受数据范围约束) + List? orgFilterIds = null; + if (orgId.HasValue && orgId.Value > 0) + { + orgFilterIds = await _orgService.GetSubtreeIdsAsync(orgId.Value); + if (allowedOrgIds != null) + orgFilterIds = orgFilterIds.Where(allowedOrgIds.Contains).ToList(); + } + + var list = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0) + .WhereIF(!string.IsNullOrWhiteSpace(keyword), x => x.UserName.Contains(keyword!) || (x.RealName != null && x.RealName.Contains(keyword!))) + .WhereIF(userIdsInRole != null, x => userIdsInRole!.Contains(x.Id)) + .WhereIF(allowedOrgIds != null, x => allowedOrgIds!.Contains(x.OrgId)) + .WhereIF(orgFilterIds != null, x => orgFilterIds!.Contains(x.OrgId)) + .OrderBy(x => x.CreateTime, OrderByType.Desc) + .ToPageListAsync(pageIndex, pageSize, total); + + var dtos = list.ToDtoList(); + await FillRolesAsync(dtos); + await FillOrgNamesAsync(dtos); + return Result>.Success(dtos); + } + catch (Exception ex) + { + return Result>.Error("查询用户列表失败", ex); + } + } + + public async Task> GetByIdAsync(long id) + { + try + { + var entity = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == id && x.IsDel == 0).FirstAsync(); + if (entity == null) return Result.Error("用户不存在或已被删除"); + var dto = entity.ToDto(); + await FillRolesAsync(new List { dto }); + await FillOrgNamesAsync(new List { dto }); + return Result.Success(dto); + } + catch (Exception ex) + { + return Result.Error("查询用户详情失败", ex); + } + } + + public async Task> AddAsync(UserDto dto) + { + if (dto == null || string.IsNullOrWhiteSpace(dto.UserName)) + return Result.Error("用户名不能为空"); + + try + { + var exists = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.UserName == dto.UserName && x.IsDel == 0).AnyAsync(); + if (exists) return Result.Error($"用户名「{dto.UserName}」已存在"); + + var entity = dto.ToEntity(); + entity.Id = 0; + entity.CreateTime = DateTime.Now; + // 新增用户初始密码必填(前端通过 InitialPassword 明文传入,落库前 PBKDF2 哈希) + var initialPassword = dto.InitialPassword; + if (string.IsNullOrWhiteSpace(initialPassword) || initialPassword.Length < 6) + return Result.Error("初始密码不能为空且长度不少于 6 位"); + entity.PasswordHash = PasswordHelper.Hash(initialPassword); + + // 角色先解析校验再落库,避免"用户已插入但角色解析失败"留下无角色用户 + var newRoleIds = new List(); + if (dto.RoleIds != null && dto.RoleIds.Count > 0) + { + foreach (var r in dto.RoleIds) + { + if (!long.TryParse(r, out var rid) || rid <= 0) + return Result.Error($"角色Id无效:{r}"); + newRoleIds.Add(rid); + } + } + + var id = await SqlSugarContext.DbContext.Insertable(entity).ExecuteReturnSnowflakeIdAsync(); + entity.Id = id; + + if (newRoleIds.Count > 0) + await ReplaceUserRolesAsync(id, newRoleIds); + + var result = entity.ToDto(); + await FillRolesAsync(new List { result }); + return Result.Success(result); + } + catch (Exception ex) + { + return Result.Error("新增用户失败", ex); + } + } + + public async Task> UpdateAsync(UserDto dto) + { + if (dto == null || !long.TryParse(dto.Id, out var id) || id <= 0) + return Result.Error("用户Id无效"); + + try + { + var entity = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == id && x.IsDel == 0).FirstAsync(); + if (entity == null) return Result.Error("用户不存在或已被删除"); + + var nameExists = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.UserName == dto.UserName && x.Id != id && x.IsDel == 0).AnyAsync(); + if (nameExists) return Result.Error($"用户名「{dto.UserName}」已存在"); + + // 防锁死:把自己禁用不允许;禁用或改角色后必须仍存在可管理用户的管理员 + if (dto.IsEnabled == 0 && id == _currentUser.UserId) + return Result.Error("不能禁用当前登录的账号"); + if (dto.IsEnabled == 0 || dto.RoleIds != null) + { + var guard = await EnsureAnotherActiveAdminAsync(id); + if (guard != null) return Result.Error(guard.Msg); + } + + var newEntity = dto.ToEntity(); + newEntity.Id = id; + // 密码、登录时间不允许在此接口覆盖 + await SqlSugarContext.DbContext.Updateable(newEntity) + .IgnoreColumns(x => new { x.CreateTime, x.IsDel, x.PasswordHash, x.LastLoginTime }) + .ExecuteCommandAsync(); + + // 角色变化时同步更新关联(传入 null 表示不改角色) + if (dto.RoleIds != null) + { + var roleIds = dto.RoleIds.Select(long.Parse).Where(r => r > 0).ToList(); + await ReplaceUserRolesAsync(id, roleIds); + } + + var result = newEntity.ToDto(); + await FillRolesAsync(new List { result }); + return Result.Success(result); + } + catch (Exception ex) + { + return Result.Error("修改用户失败", ex); + } + } + + public async Task DeleteAsync(long id) + { + if (id <= 0) return Result.Error("用户Id无效"); + if (id == _currentUser.UserId) + return Result.Error("不能删除当前登录的账号"); + + try + { + var exists = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == id && x.IsDel == 0).AnyAsync(); + if (!exists) return Result.Error("用户不存在或已被删除"); + + // 防锁死:删除后必须仍存在可管理用户的管理员 + var guard = await EnsureAnotherActiveAdminAsync(id); + if (guard != null) return guard; + + await SqlSugarContext.DbContext.Updateable() + .SetColumns(x => x.IsDel == 1) + .Where(x => x.Id == id).ExecuteCommandAsync(); + await SqlSugarContext.DbContext.Deleteable() + .Where(x => x.UserId == id).ExecuteCommandAsync(); + return Result.Success(); + } + catch (Exception ex) + { + return Result.Error("删除用户失败", ex); + } + } + + public async Task AssignRolesAsync(long userId, List roleIds) + { + if (userId <= 0) return Result.Error("用户Id无效"); + try + { + var exists = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == userId && x.IsDel == 0).AnyAsync(); + if (!exists) return Result.Error("用户不存在或已被删除"); + + // 防锁死:改角色后必须仍存在可管理用户的管理员 + var guard = await EnsureAnotherActiveAdminAsync(userId); + if (guard != null) return guard; + + await ReplaceUserRolesAsync(userId, roleIds ?? new List()); + return Result.Success(); + } + catch (Exception ex) + { + return Result.Error("分配角色失败", ex); + } + } + + public async Task ResetPasswordAsync(long userId, string newPassword) + { + if (userId <= 0) return Result.Error("用户Id无效"); + if (string.IsNullOrWhiteSpace(newPassword) || newPassword.Length < 6) + return Result.Error("新密码不能为空且长度不少于 6 位"); + try + { + var exists = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == userId && x.IsDel == 0).AnyAsync(); + if (!exists) return Result.Error("用户不存在或已被删除"); + + await SqlSugarContext.DbContext.Updateable() + .SetColumns(x => x.PasswordHash == PasswordHelper.Hash(newPassword)) + .Where(x => x.Id == userId).ExecuteCommandAsync(); + return Result.Success(); + } + catch (Exception ex) + { + return Result.Error("重置密码失败", ex); + } + } + + public async Task SetEnabledAsync(long userId, bool enabled) + { + if (userId <= 0) return Result.Error("用户Id无效"); + if (!enabled && userId == _currentUser.UserId) + return Result.Error("不能禁用当前登录的账号"); + try + { + // 防锁死:禁用后必须仍存在可管理用户的管理员 + if (!enabled) + { + var guard = await EnsureAnotherActiveAdminAsync(userId); + if (guard != null) return guard; + } + + await SqlSugarContext.DbContext.Updateable() + .SetColumns(x => x.IsEnabled == (byte)(enabled ? 1 : 0)) + .Where(x => x.Id == userId).ExecuteCommandAsync(); + return Result.Success(); + } + catch (Exception ex) + { + return Result.Error("设置用户状态失败", ex); + } + } + + public async Task>> GetOptionsAsync() + { + try + { + var list = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0 && x.IsEnabled == 1) + .OrderBy(x => x.UserName) + .ToListAsync(); + return Result>.Success(list.ToOptionDtoList()); + } + catch (Exception ex) + { + return Result>.Error("查询用户选项失败", ex); + } + } + + /// 全量覆盖用户-角色关联 + private static async Task ReplaceUserRolesAsync(long userId, List roleIds) + { + var db = SqlSugarContext.DbContext; + await db.Deleteable().Where(x => x.UserId == userId).ExecuteCommandAsync(); + if (roleIds.Count > 0) + { + var now = DateTime.Now; + var links = roleIds.Distinct().Select(r => new UserRoleEntity { UserId = userId, RoleId = r, CreateTime = now }).ToList(); + await db.Insertable(links).ExecuteCommandAsync(); + } + } + + /// + /// 防锁死校验:排除 excludeUserId 后,系统必须仍存在「已启用且拥有 user:manage 权限」的用户, + /// 否则返回 Error(保证管理员不会把所有管理员删光/禁用光导致无人可登录管理) + /// + private async Task EnsureAnotherActiveAdminAsync(long excludeUserId) + { + var db = SqlSugarContext.DbContext; + var permId = await db.Queryable() + .Where(x => x.Code == "user:manage" && x.IsDel == 0) + .Select(x => x.Id).FirstAsync(); + if (permId == 0) return null; // 权限码不存在时不拦,避免权限体系变更后管理功能彻底锁死 + + var roleIds = await db.Queryable() + .Where(x => x.PermissionId == permId) + .Select(x => x.RoleId).ToListAsync(); + if (roleIds.Count == 0) return null; + + var userIds = await db.Queryable() + .Where(x => roleIds.Contains(x.RoleId)) + .Select(x => x.UserId).ToListAsync(); + if (userIds.Count == 0) + return Result.Error("操作被拒绝:系统将没有任何可管理用户的管理员"); + + var exists = await db.Queryable() + .AnyAsync(x => userIds.Contains(x.Id) && x.IsDel == 0 && x.IsEnabled == 1 && x.Id != excludeUserId); + return exists ? null : Result.Error("操作被拒绝:系统至少需要保留一名已启用且拥有用户管理权限的账号"); + } + + /// 给用户 DTO 填充所属组织名称(组织链路径,如 公司/实验室/部门) + private async Task FillOrgNamesAsync(List dtos) + { + if (dtos == null || dtos.Count == 0) return; + var orgIds = dtos.Where(d => long.TryParse(d.OrgId, out var oid) && oid > 0) + .Select(d => long.Parse(d.OrgId)).Distinct().ToList(); + if (orgIds.Count == 0) return; + + var allOrgs = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0).ToListAsync(); + var orgMap = allOrgs.ToDictionary(o => o.Id, o => o); + + foreach (var d in dtos) + { + if (!long.TryParse(d.OrgId, out var oid) || !orgMap.TryGetValue(oid, out var org)) + continue; + // 沿父级向上拼组织路径 + var path = new List(); + var cursor = org; + var guard = 0; + while (cursor != null && guard++ < 10) + { + path.Insert(0, cursor.Name); + cursor = cursor.ParentId > 0 && orgMap.TryGetValue(cursor.ParentId, out var parent) ? parent : null; + } + d.OrgName = string.Join(" / ", path); + } + } + + /// 给用户 DTO 填充角色Id列表与角色名称 + private static async Task FillRolesAsync(List dtos) + { + if (dtos == null || dtos.Count == 0) return; + var db = SqlSugarContext.DbContext; + var userIds = dtos.Select(d => long.Parse(d.Id)).ToList(); + + var links = await db.Queryable() + .Where(x => userIds.Contains(x.UserId)).ToListAsync(); + var roleIds = links.Select(l => l.RoleId).Distinct().ToList(); + var roles = roleIds.Count > 0 + ? await db.Queryable().Where(x => roleIds.Contains(x.Id) && x.IsDel == 0).ToListAsync() + : new List(); + var roleMap = roles.ToDictionary(r => r.Id, r => r.Name); + + foreach (var d in dtos) + { + var uid = long.Parse(d.Id); + var myRoleIds = links.Where(l => l.UserId == uid).Select(l => l.RoleId).ToList(); + d.RoleIds = myRoleIds.Select(r => r.ToString()).ToList(); + d.RoleNames = string.Join(",", myRoleIds.Where(r => roleMap.ContainsKey(r)).Select(r => roleMap[r])); + } + } } } diff --git a/Service/Interface/Config/IDeviceService.cs b/Service/Interface/Config/IDeviceService.cs index 680d191..0b9cd27 100644 --- a/Service/Interface/Config/IDeviceService.cs +++ b/Service/Interface/Config/IDeviceService.cs @@ -14,7 +14,7 @@ namespace Service.Interface.Config /// /// 分页查询设备列表(支持关键字搜索编号/名称/类型,可按所属产品筛选) /// - Task>> GetPagedAsync(int pageIndex, int pageSize, RefAsync total, string? keyword, long? productId = null); + Task>> GetPagedAsync(int pageIndex, int pageSize, RefAsync total, string? keyword, long? productId = null, long? gatewayId = null); /// /// 根据 Id 获取设备详情 diff --git a/Service/Interface/Config/IGatewayService.cs b/Service/Interface/Config/IGatewayService.cs index b955005..1d5bdc8 100644 --- a/Service/Interface/Config/IGatewayService.cs +++ b/Service/Interface/Config/IGatewayService.cs @@ -1,10 +1,25 @@ -namespace Service.Interface +using Model; +using Model.Dto.Config; +using Model.Entity.Config; +using ORM; +using SqlSugar; +using System.Net.Sockets; +using System.Net; +using System.IO.Ports; + +namespace Service.Interface.Config { /// /// 网关管理 服务接口 /// public interface IGatewayService { - // TODO: 定义 网关管理 相关方法 + Task>> GetPagedAsync(int pageIndex, int pageSize, RefAsync total, string? keyword = null, int? protocolType = null); + Task> GetByIdAsync(long id); + Task> AddAsync(GatewayDto dto); + Task> UpdateAsync(GatewayDto dto); + Task DeleteAsync(long id); + Task> TestConnectionAsync(long id); + Task>> GetOptionsAsync(); } } diff --git a/Service/Interface/System/IAuditLogService.cs b/Service/Interface/System/IAuditLogService.cs new file mode 100644 index 0000000..b9c7010 --- /dev/null +++ b/Service/Interface/System/IAuditLogService.cs @@ -0,0 +1,19 @@ +using Model; +using Model.Dto.System; +using SqlSugar; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Service.Interface +{ + /// + /// 审计日志 服务接口 + /// + public interface IAuditLogService + { + /// 分页查询审计日志(可按操作人/操作类型/操作对象/时间段筛选) + Task>> GetPagedAsync(int pageIndex, int pageSize, RefAsync total, + string? keyword = null, string? operationType = null, string? operationTarget = null, + DateTime? startTime = null, DateTime? endTime = null); + } +} diff --git a/Service/Interface/System/IAuditRecorder.cs b/Service/Interface/System/IAuditRecorder.cs new file mode 100644 index 0000000..fa5d7c6 --- /dev/null +++ b/Service/Interface/System/IAuditRecorder.cs @@ -0,0 +1,23 @@ +namespace Service.Interface +{ + /// + /// 审计日志记录器:业务服务注入后一行代码记录关键操作(操作人/角色自动取自当前登录上下文) + /// + public interface IAuditRecorder + { + /// + /// 记录一条审计日志 + /// + /// 操作类型(Create/Update/Delete/Login/Command等) + /// 操作对象(IotDevice/Gateway/User/Role等) + /// 操作对象Id + /// 关联设备Id(0=无关) + /// 操作前值(JSON 或摘要) + /// 操作后值(JSON 或摘要) + /// 操作IP(null 自动留空) + /// 操作描述 + Task RecordAsync(string operationType, string operationTarget, long targetId, + long deviceId = 0, string? oldValue = null, string? newValue = null, + string? ip = null, string? description = null); + } +} diff --git a/Service/Interface/System/IAuthService.cs b/Service/Interface/System/IAuthService.cs new file mode 100644 index 0000000..816a101 --- /dev/null +++ b/Service/Interface/System/IAuthService.cs @@ -0,0 +1,23 @@ +using Model; +using Model.Dto.System; + +namespace Service.Interface +{ + /// + /// 认证授权 服务接口(登录 / 当前用户 / 修改密码) + /// + public interface IAuthService + { + /// 用户名密码登录,签发 JWT 并返回用户信息+权限列表 + Task> LoginAsync(LoginDto dto, string? ip); + + /// 退出登录(记录审计日志,JWT 无状态因此不做服务端吊销) + Task LogoutAsync(); + + /// 获取当前登录用户信息 + 权限列表 + Task> GetMeAsync(); + + /// 当前用户修改自己的密码 + Task ChangePasswordAsync(ChangePasswordDto dto); + } +} diff --git a/Service/Interface/System/ICurrentUser.cs b/Service/Interface/System/ICurrentUser.cs new file mode 100644 index 0000000..fbf8b83 --- /dev/null +++ b/Service/Interface/System/ICurrentUser.cs @@ -0,0 +1,19 @@ +namespace Service.Interface +{ + /// + /// 当前登录用户上下文(从 JWT Claims 解析,IOT_API 实现注入) + /// + public interface ICurrentUser + { + bool IsAuthenticated { get; } + long UserId { get; } + string UserName { get; } + List RoleIds { get; } + List RoleNames { get; } + /// 当前用户拥有的权限编码列表(登录时写入 Token) + List Permissions { get; } + /// 数据范围(1=全部, 2=本实验室) + byte DataScope { get; } + bool HasPermission(string code); + } +} diff --git a/Service/Interface/System/IOrgService.cs b/Service/Interface/System/IOrgService.cs new file mode 100644 index 0000000..32a2633 --- /dev/null +++ b/Service/Interface/System/IOrgService.cs @@ -0,0 +1,31 @@ +using Model; +using Model.Dto.System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Service.Interface +{ + /// + /// 组织架构管理 服务接口(公司/实验室/部门/班组树) + /// + public interface IOrgService + { + /// 完整组织树(嵌套子级) + Task>> GetTreeAsync(); + + /// 新增组织节点 + Task> AddAsync(OrgDto dto); + + /// 修改组织节点(系统节点层级类型变更需校验无子级) + Task> UpdateAsync(OrgDto dto); + + /// 删除组织节点(有子级或有用户挂靠时不可删) + Task DeleteAsync(long id); + + /// 组织下拉选项(平铺) + Task>> GetOptionsAsync(); + + /// 取指定组织及其全部子孙组织 Id(数据权限过滤用) + Task> GetSubtreeIdsAsync(long orgId); + } +} diff --git a/Service/Interface/System/IRoleService.cs b/Service/Interface/System/IRoleService.cs index 800ed6d..de8ca37 100644 --- a/Service/Interface/System/IRoleService.cs +++ b/Service/Interface/System/IRoleService.cs @@ -1,3 +1,9 @@ +using Model; +using Model.Dto.System; +using SqlSugar; +using System.Collections.Generic; +using System.Threading.Tasks; + namespace Service.Interface { /// @@ -5,6 +11,27 @@ namespace Service.Interface /// public interface IRoleService { - // TODO: 定义 角色权限管理 相关方法 + /// 分页查询角色列表(关键字匹配编码/名称) + Task>> GetPagedAsync(int pageIndex, int pageSize, RefAsync total, string? keyword = null); + + Task> GetByIdAsync(long id); + + /// 新增角色(校验编码唯一) + Task> AddAsync(RoleDto dto); + + /// 修改角色(系统内置角色不允许改编码) + Task> UpdateAsync(RoleDto dto); + + /// 删除角色(系统内置角色不可删;有关联用户时不可删) + Task DeleteAsync(long id); + + /// 分配权限(全量覆盖该角色的权限关联) + Task AssignPermissionsAsync(long roleId, List permissionIds); + + /// 角色下拉选项 + Task>> GetOptionsAsync(); + + /// 查询全部权限列表 + Task>> GetPermissionsAsync(); } } diff --git a/Service/Interface/System/IUserService.cs b/Service/Interface/System/IUserService.cs index a49b995..12d763c 100644 --- a/Service/Interface/System/IUserService.cs +++ b/Service/Interface/System/IUserService.cs @@ -1,3 +1,9 @@ +using Model; +using Model.Dto.System; +using SqlSugar; +using System.Collections.Generic; +using System.Threading.Tasks; + namespace Service.Interface { /// @@ -5,6 +11,30 @@ namespace Service.Interface /// public interface IUserService { - // TODO: 定义 用户管理 相关方法 + /// 分页查询用户列表(关键字匹配用户名/姓名,可按角色、组织节点筛选;受数据范围约束) + Task>> GetPagedAsync(int pageIndex, int pageSize, RefAsync total, string? keyword = null, long? roleId = null, long? orgId = null); + + Task> GetByIdAsync(long id); + + /// 新增用户(校验用户名唯一,密码必填,可同时分配角色) + Task> AddAsync(UserDto dto); + + /// 修改用户(不改密码,角色走 AssignRoles) + Task> UpdateAsync(UserDto dto); + + /// 删除用户(软删,同时清理角色关联) + Task DeleteAsync(long id); + + /// 分配角色(全量覆盖该用户的角色关联) + Task AssignRolesAsync(long userId, List roleIds); + + /// 管理员重置用户密码 + Task ResetPasswordAsync(long userId, string newPassword); + + /// 启用/禁用用户 + Task SetEnabledAsync(long userId, bool enabled); + + /// 用户下拉选项 + Task>> GetOptionsAsync(); } } diff --git a/Service/Service.csproj b/Service/Service.csproj index 842efd2..4f8782b 100644 --- a/Service/Service.csproj +++ b/Service/Service.csproj @@ -11,6 +11,7 @@ +