using Admin.NET.Plugin.AiDOP.Dto.S8; using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse; using Admin.NET.Plugin.AiDOP.Infrastructure; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; namespace Admin.NET.Plugin.AiDOP.Service.S8; /// /// S8 配置页:操作员(员工)↔ 系统账号绑定。 /// 仅维护 EmployeeMaster.SysUserId 的 1:1 弱关联;不动 SysUser/SysRole/SysUserRole。 /// /// S8-P0-3-MASTERDATA-TRUSTED-SCOPE-1 作用域合同(五个入口统一): /// 租户与工厂**一律由 从认证身份解析**, /// 客户端传入的 factoryRefId / tenantId / body.FactoryRefId /// **只被视为筛选意图、不承担任何授权作用,且当前实现直接忽略**。 /// /// 修复前(实测可被普通租户账号利用):ListAsync 是 /// ClearFilter() + WhereIF(factoryRefId.HasValue, …) —— 谓词**可选**, /// 不传即全库无边界;`GET /config/operator-bindings` 返回 500 行且全部属于其它租户 /// (工厂 1000 × 498 / 1329900200002 × 2,本租户 0 行)。 /// BindAsync / UnbindAsync / ListSysUsersAsync 则各自用不同口径, /// 出现过「List 用 ClearFilter、Bind 不用」的不一致。 /// /// 修复后:`EmployeeMaster` 的 ClearFilter() 保留(其 tenant_id 属 S0 业务域, /// 与登录 token 的系统租户历史错位,不清会全过滤为 0 行),但**必须**同时带 /// FactoryRefId == 可信工厂 这一强制硬边界 —— 与 CURRENT.md 的 /// 「ClearFilter 必须同时具备显式业务边界」铁律一致。 /// public class S8OperatorBindingService : ITransient { private readonly SqlSugarRepository _empRep; private readonly SqlSugarRepository _sysUserRep; private readonly UserManager _userManager; public S8OperatorBindingService( SqlSugarRepository empRep, SqlSugarRepository sysUserRep, UserManager userManager) { _empRep = empRep; _sysUserRep = sysUserRep; _userManager = userManager; } /// 按可信作用域列出本工厂员工及其绑定状态。factoryRefId 不再由调用方决定。 public async Task> ListAsync(S8TrustedScope scope, string? bindStatus, string? keyword) { var emps = await ScopedEmployees(scope) .WhereIF(!string.IsNullOrWhiteSpace(keyword), x => x.Employee.Contains(keyword!) || (x.Name != null && x.Name.Contains(keyword!))) .Take(500) .Select(x => new { x.Id, x.Name, x.Employee, x.FactoryRefId, x.SysUserId }) .ToListAsync(); var sysUserIds = emps.Where(e => e.SysUserId.HasValue && e.SysUserId.Value > 0) .Select(e => e.SysUserId!.Value).Distinct().ToList(); var userMap = sysUserIds.Count == 0 ? new Dictionary() : (await _sysUserRep.AsQueryable() .Where(u => sysUserIds.Contains(u.Id) && u.TenantId == scope.TenantId) .Select(u => new { u.Id, u.RealName, u.Account }) .ToListAsync()) .ToDictionary(u => u.Id, u => string.IsNullOrWhiteSpace(u.RealName) ? u.Account : u.RealName); var rows = emps.Select(e => { var bound = e.SysUserId.HasValue && e.SysUserId.Value > 0 && userMap.ContainsKey(e.SysUserId.Value); return new AdoS8OperatorBindingRowDto { EmployeeId = e.Id, EmpCode = e.Employee, Name = e.Name, FactoryRefId = e.FactoryRefId, SysUserId = e.SysUserId, SysUserName = bound ? userMap[e.SysUserId!.Value] : null, BindStatus = bound ? "BOUND" : "UNBOUND" }; }); if (!string.IsNullOrWhiteSpace(bindStatus) && (bindStatus == "BOUND" || bindStatus == "UNBOUND")) rows = rows.Where(r => r.BindStatus == bindStatus); return rows .OrderBy(r => r.BindStatus == "BOUND" ? 0 : 1) .ThenBy(r => r.EmpCode) .ToList(); } public async Task> ListSysUsersAsync(S8TrustedScope scope, string? keyword, long? excludeEmployeeId) { var users = await _sysUserRep.AsQueryable() .Where(u => u.TenantId == scope.TenantId) .WhereIF(!string.IsNullOrWhiteSpace(keyword), u => u.Account.Contains(keyword!) || u.RealName.Contains(keyword!)) .Take(200) .Select(u => new { u.Id, u.Account, u.RealName, u.Status }) .ToListAsync(); if (users.Count == 0) return new(); // 标注哪些 sysUser 已被其它 employee 绑定(用于前端禁选/隐藏); // 当前 employee 自己绑定的 sysUser 在 alreadyBoundEmployeeId == excludeEmployeeId 时会被前端放行。 // S8-P0-3:占用查询必须限定在可信工厂内,否则会把其它租户的占用情况泄漏出去。 var ids = users.Select(u => u.Id).ToList(); var bound = await ScopedEmployees(scope) .Where(e => e.SysUserId.HasValue && ids.Contains(e.SysUserId!.Value)) .Select(e => new { e.Id, e.SysUserId }) .ToListAsync(); var boundMap = bound.GroupBy(b => b.SysUserId!.Value) .ToDictionary(g => g.Key, g => g.First().Id); return users.Select(u => new AdoS8ConfigSysUserRowDto { Id = u.Id, Account = u.Account, RealName = u.RealName, Status = (int)u.Status, AlreadyBoundEmployeeId = boundMap.TryGetValue(u.Id, out var empId) ? empId : null }).ToList(); } public async Task BindAsync(S8TrustedScope scope, AdoS8OperatorBindingCreateDto dto) { if (dto.EmployeeId <= 0) throw new S8BizException("员工 ID 不能为空"); if (dto.SysUserId <= 0) throw new S8BizException("系统账号 ID 不能为空"); // S8-P0-3:员工必须属于可信工厂。跨工厂 / 跨租户的 employeeId 一律按「不存在」处理, // 文案与本租户内真实不存在的情况完全一致,避免被用来探测他租户员工是否存在。 var emp = await ScopedEmployee(scope, dto.EmployeeId) ?? throw new S8BizException("员工不存在"); var user = await _sysUserRep.GetFirstAsync(x => x.Id == dto.SysUserId && x.TenantId == scope.TenantId) ?? throw new S8BizException("系统账号不存在或不在当前租户"); // 同一 sysUser 不允许绑定到另一个 employee(冲突检测同样限定在可信工厂内) var conflict = await ScopedEmployees(scope) .Where(x => x.SysUserId == dto.SysUserId && x.Id != dto.EmployeeId) .FirstAsync(); if (conflict != null) throw new S8BizException($"系统账号 {user.Account} 已绑定至其它员工 {conflict.Name ?? conflict.Employee}"); emp.SysUserId = dto.SysUserId; emp.UpdateTime = DateTime.Now; emp.UpdateUser = _userManager.Account; await _empRep.AsUpdateable(emp) .UpdateColumns(it => new { it.SysUserId, it.UpdateTime, it.UpdateUser }) .ExecuteCommandAsync(); return new AdoS8OperatorBindingRowDto { EmployeeId = emp.Id, EmpCode = emp.Employee, Name = emp.Name, FactoryRefId = emp.FactoryRefId, SysUserId = dto.SysUserId, SysUserName = string.IsNullOrWhiteSpace(user.RealName) ? user.Account : user.RealName, BindStatus = "BOUND" }; } public async Task UnbindAsync(S8TrustedScope scope, long employeeId) { if (employeeId <= 0) throw new S8BizException("员工 ID 不能为空"); var emp = await ScopedEmployee(scope, employeeId) ?? throw new S8BizException("员工不存在"); if (!emp.SysUserId.HasValue) return; emp.SysUserId = null; emp.UpdateTime = DateTime.Now; emp.UpdateUser = _userManager.Account; await _empRep.AsUpdateable(emp) .UpdateColumns(it => new { it.SysUserId, it.UpdateTime, it.UpdateUser }) .ExecuteCommandAsync(); } /// /// S8-P0-3:EmployeeMaster 的唯一合法访问口径。 /// ClearFilter 是必要的(EmployeeMaster.tenant_id 属 S0 业务域,与登录 token 的系统租户历史错位), /// 但**必须**同时带 FactoryRefId 强制硬边界 —— 谓词不可选、不接受调用方覆盖。 /// private ISugarQueryable ScopedEmployees(S8TrustedScope scope) => _empRep.AsQueryable().ClearFilter() .Where(x => x.FactoryRefId == scope.FactoryId); private async Task ScopedEmployee(S8TrustedScope scope, long employeeId) => await ScopedEmployees(scope).Where(x => x.Id == employeeId).FirstAsync(); }