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 @@
+