using Admin.NET.Core; using Admin.NET.Plugin.AiDOP.Service.S8; namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8; /// /// S8 语境下的「一个人」。唯一身份是 SysUser.Id, /// 其余字段只用于展示,不参与任何判定。 /// public sealed record S8OperatorUser(long UserId, string Account, string? RealName) { /// 展示名:有真实姓名用姓名,否则回落账号。绝不回落成裸数字 ID。 public string DisplayName => string.IsNullOrWhiteSpace(RealName) ? Account : RealName!; } /// /// S8-SYSUSER-ONLY-1:「这个账号在当前租户下能不能作为 S8 的人」的唯一判定实现。 /// /// 为什么必须只有一份:认领 / 转派 / 提交复检 / 通知 四条路径都要回答同一个问题。 /// 各写一套 SQL 的直接后果在本仓已经发生过一次 —— S8OperatorBindingService 的 /// ListAsyncClearFilter() + 可选谓词、BindAsync 用另一套口径, /// 于是 GET /config/operator-bindings 曾返回 500 行全部属于其它租户。 /// 判据必须物理同源,不是「几处写成一样」。 /// /// 判据三条,缺一不可:主键命中 · TenantId 等于传入租户 · Status = Enable。 /// SysUser 继承 EntityBaseTenantOrg没有软删列,因此不存在 IsDelete 判据; /// 停用账号一律按「不可作为 S8 处理人」处理。 /// /// Factory 不参与,且不得加回来:真库 factory_ref_id = 1000 横跨 /// 824585161322565 / 797403760988229 两个租户,按工厂校验会把别家的人判为合法。 /// 租户是唯一安全边界。 /// /// 跨租户一律按「不存在」处理:返回 null / 抛「不存在或已停用」, /// 与本租户内真实不存在的情况文案完全一致,不泄漏他租户账号是否存在。 /// public interface IS8UserScopeValidator { /// 查找租户内的可用账号;不存在 / 跨租户 / 已停用一律返回 null。 Task FindActiveUserAsync(long tenantId, long userId); /// 校验并返回;不通过抛 (→ 400)。 Task EnsureActiveUserAsync(long tenantId, long userId, string subject); /// /// 批量校验。返回通过校验的那些账号,顺序不保证、不含重复。 /// 供后续「规则处理账号池」复用 —— 池成员的合法性判据必须与单人认领完全一致。 /// Task> ValidateUsersAsync(long tenantId, IEnumerable userIds); /// 展示水合:账号 Id → 展示信息。只用于显示,缺失的键不出现在结果里。 Task> ResolveDisplayAsync(long tenantId, IEnumerable userIds); } /// /// 正式实现。生产代码里只有这一份 —— 接口的存在是为了给测试留 seam, /// 不是为了允许出现第二种判据。 /// public sealed class S8UserScopeValidator : IS8UserScopeValidator, ITransient { /// 单次展示水合的上限。人员选择器与列表水合都不该无界扫全租户。 internal const int MaxHydrationKeys = 500; private readonly SqlSugarRepository _userRep; public S8UserScopeValidator(SqlSugarRepository userRep) => _userRep = userRep; public async Task FindActiveUserAsync(long tenantId, long userId) { if (tenantId <= 0 || userId <= 0) return null; var row = await ActiveInTenant(tenantId) .Where(x => x.Id == userId) .Select(x => new { x.Id, x.Account, x.RealName }) .FirstAsync(); return row == null ? null : new S8OperatorUser(row.Id, row.Account, row.RealName); } public async Task EnsureActiveUserAsync(long tenantId, long userId, string subject) { if (userId <= 0) throw new S8BizException($"请选择{subject}"); return await FindActiveUserAsync(tenantId, userId) // 跨租户与本租户不存在共用同一文案,见接口注释。 ?? throw new S8BizException($"{subject}不存在或已停用"); } public async Task> ValidateUsersAsync(long tenantId, IEnumerable userIds) { var ids = Normalize(userIds); if (tenantId <= 0 || ids.Count == 0) return Array.Empty(); var rows = await ActiveInTenant(tenantId) .Where(x => ids.Contains(x.Id)) .Select(x => new { x.Id, x.Account, x.RealName }) .ToListAsync(); return rows.Select(x => new S8OperatorUser(x.Id, x.Account, x.RealName)).ToList(); } public async Task> ResolveDisplayAsync(long tenantId, IEnumerable userIds) { var users = await ValidateUsersAsync(tenantId, userIds); return users.ToDictionary(u => u.UserId); } /// /// 判据的唯一表达式。ClearFilter():边界由本方法显式声明, /// 结果与全局 multi-tenant AOP 是否开启无关(两者判据同为 tenant_id == 传入租户)。 /// private ISugarQueryable ActiveInTenant(long tenantId) => _userRep.AsQueryable().ClearFilter() .Where(x => x.TenantId == tenantId && x.Status == StatusEnum.Enable); private static List Normalize(IEnumerable? userIds) => (userIds ?? Enumerable.Empty()) .Where(x => x > 0) .Distinct() .Take(MaxHydrationKeys) .ToList(); }