S8UserScopeValidator.cs 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. using Admin.NET.Core;
  2. using Admin.NET.Plugin.AiDOP.Service.S8;
  3. namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  4. /// <summary>
  5. /// S8 语境下的「一个人」。<b>唯一身份是 <see cref="UserId"/>(<c>SysUser.Id</c>)</b>,
  6. /// 其余字段只用于展示,不参与任何判定。
  7. /// </summary>
  8. public sealed record S8OperatorUser(long UserId, string Account, string? RealName)
  9. {
  10. /// <summary>展示名:有真实姓名用姓名,否则回落账号。绝不回落成裸数字 ID。</summary>
  11. public string DisplayName => string.IsNullOrWhiteSpace(RealName) ? Account : RealName!;
  12. }
  13. /// <summary>
  14. /// S8-SYSUSER-ONLY-1:「这个账号在当前租户下能不能作为 S8 的人」的<b>唯一</b>判定实现。
  15. ///
  16. /// <para><b>为什么必须只有一份</b>:认领 / 转派 / 提交复检 / 通知 四条路径都要回答同一个问题。
  17. /// 各写一套 SQL 的直接后果在本仓已经发生过一次 —— <c>S8OperatorBindingService</c> 的
  18. /// <c>ListAsync</c> 用 <c>ClearFilter()</c> + 可选谓词、<c>BindAsync</c> 用另一套口径,
  19. /// 于是 <c>GET /config/operator-bindings</c> 曾返回 500 行全部属于其它租户。
  20. /// 判据必须物理同源,不是「几处写成一样」。</para>
  21. ///
  22. /// <para><b>判据三条,缺一不可</b>:主键命中 · <c>TenantId</c> 等于传入租户 · <c>Status = Enable</c>。
  23. /// <c>SysUser</c> 继承 <c>EntityBaseTenantOrg</c>,<b>没有软删列</b>,因此不存在 <c>IsDelete</c> 判据;
  24. /// 停用账号一律按「不可作为 S8 处理人」处理。</para>
  25. ///
  26. /// <para><b>Factory 不参与,且不得加回来</b>:真库 <c>factory_ref_id = 1000</c> 横跨
  27. /// 824585161322565 / 797403760988229 两个租户,按工厂校验会把别家的人判为合法。
  28. /// 租户是唯一安全边界。</para>
  29. ///
  30. /// <para><b>跨租户一律按「不存在」处理</b>:返回 null / 抛「不存在或已停用」,
  31. /// 与本租户内真实不存在的情况文案完全一致,不泄漏他租户账号是否存在。</para>
  32. /// </summary>
  33. public interface IS8UserScopeValidator
  34. {
  35. /// <summary>查找租户内的可用账号;不存在 / 跨租户 / 已停用一律返回 null。</summary>
  36. Task<S8OperatorUser?> FindActiveUserAsync(long tenantId, long userId);
  37. /// <summary>校验并返回;不通过抛 <see cref="S8BizException"/>(→ 400)。</summary>
  38. Task<S8OperatorUser> EnsureActiveUserAsync(long tenantId, long userId, string subject);
  39. /// <summary>
  40. /// 批量校验。返回<b>通过校验的那些</b>账号,顺序不保证、不含重复。
  41. /// 供后续「规则处理账号池」复用 —— 池成员的合法性判据必须与单人认领完全一致。
  42. /// </summary>
  43. Task<IReadOnlyList<S8OperatorUser>> ValidateUsersAsync(long tenantId, IEnumerable<long> userIds);
  44. /// <summary>展示水合:账号 Id → 展示信息。<b>只用于显示</b>,缺失的键不出现在结果里。</summary>
  45. Task<IReadOnlyDictionary<long, S8OperatorUser>> ResolveDisplayAsync(long tenantId, IEnumerable<long> userIds);
  46. }
  47. /// <summary>
  48. /// 正式实现。<b>生产代码里只有这一份</b> —— 接口的存在是为了给测试留 seam,
  49. /// 不是为了允许出现第二种判据。
  50. /// </summary>
  51. public sealed class S8UserScopeValidator : IS8UserScopeValidator, ITransient
  52. {
  53. /// <summary>单次展示水合的上限。人员选择器与列表水合都不该无界扫全租户。</summary>
  54. internal const int MaxHydrationKeys = 500;
  55. private readonly SqlSugarRepository<SysUser> _userRep;
  56. public S8UserScopeValidator(SqlSugarRepository<SysUser> userRep) => _userRep = userRep;
  57. public async Task<S8OperatorUser?> FindActiveUserAsync(long tenantId, long userId)
  58. {
  59. if (tenantId <= 0 || userId <= 0) return null;
  60. var row = await ActiveInTenant(tenantId)
  61. .Where(x => x.Id == userId)
  62. .Select(x => new { x.Id, x.Account, x.RealName })
  63. .FirstAsync();
  64. return row == null ? null : new S8OperatorUser(row.Id, row.Account, row.RealName);
  65. }
  66. public async Task<S8OperatorUser> EnsureActiveUserAsync(long tenantId, long userId, string subject)
  67. {
  68. if (userId <= 0) throw new S8BizException($"请选择{subject}");
  69. return await FindActiveUserAsync(tenantId, userId)
  70. // 跨租户与本租户不存在共用同一文案,见接口注释。
  71. ?? throw new S8BizException($"{subject}不存在或已停用");
  72. }
  73. public async Task<IReadOnlyList<S8OperatorUser>> ValidateUsersAsync(long tenantId, IEnumerable<long> userIds)
  74. {
  75. var ids = Normalize(userIds);
  76. if (tenantId <= 0 || ids.Count == 0) return Array.Empty<S8OperatorUser>();
  77. var rows = await ActiveInTenant(tenantId)
  78. .Where(x => ids.Contains(x.Id))
  79. .Select(x => new { x.Id, x.Account, x.RealName })
  80. .ToListAsync();
  81. return rows.Select(x => new S8OperatorUser(x.Id, x.Account, x.RealName)).ToList();
  82. }
  83. public async Task<IReadOnlyDictionary<long, S8OperatorUser>> ResolveDisplayAsync(long tenantId, IEnumerable<long> userIds)
  84. {
  85. var users = await ValidateUsersAsync(tenantId, userIds);
  86. return users.ToDictionary(u => u.UserId);
  87. }
  88. /// <summary>
  89. /// 判据的唯一表达式。<c>ClearFilter()</c>:边界由本方法显式声明,
  90. /// 结果与全局 multi-tenant AOP 是否开启无关(两者判据同为 tenant_id == 传入租户)。
  91. /// </summary>
  92. private ISugarQueryable<SysUser> ActiveInTenant(long tenantId) =>
  93. _userRep.AsQueryable().ClearFilter()
  94. .Where(x => x.TenantId == tenantId && x.Status == StatusEnum.Enable);
  95. private static List<long> Normalize(IEnumerable<long>? userIds) =>
  96. (userIds ?? Enumerable.Empty<long>())
  97. .Where(x => x > 0)
  98. .Distinct()
  99. .Take(MaxHydrationKeys)
  100. .ToList();
  101. }