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