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();
}