S8VerifierEligibility.cs 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. using Admin.NET.Plugin.AiDOP.Const.S8;
  2. using Admin.NET.Plugin.AiDOP.Entity.S8;
  3. using Admin.NET.Plugin.AiDOP.Service.S8;
  4. namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  5. /// <summary>复核人资格判定结果。拒绝时带原因码,让「没审核权限」与「不在复核池」可分辨。</summary>
  6. public sealed record S8VerifierEligibilityResult(bool Allowed, string? ReasonCode, string? Message)
  7. {
  8. public static S8VerifierEligibilityResult Pass() => new(true, null, null);
  9. public static S8VerifierEligibilityResult Deny(string reason, string message) => new(false, reason, message);
  10. }
  11. /// <summary>复核人被拒的原因码。</summary>
  12. public static class S8VerifierDenyReason
  13. {
  14. /// <summary>账号没有「审核」这个动作的权限(角色维度)。</summary>
  15. public const string NoActionPermission = "verifier_no_action_permission";
  16. /// <summary>账号不在这条规则的复核账号池里(责任维度)。</summary>
  17. public const string NotInReviewerPool = "verifier_not_in_reviewer_pool";
  18. /// <summary>账号不存在 / 已停用 / 不在本租户。</summary>
  19. public const string NotUsable = "verifier_not_usable";
  20. }
  21. /// <summary>复核人候选(提交复核时的下拉项)。</summary>
  22. public sealed class S8VerifierCandidateDto
  23. {
  24. public long UserId { get; set; }
  25. public string Account { get; set; } = string.Empty;
  26. public string DisplayName { get; set; } = string.Empty;
  27. /// <summary>页面展示文案:<c>RealName (Account)</c>。前后端共用同一份,避免两处各拼各的。</summary>
  28. public string Label { get; set; } = string.Empty;
  29. }
  30. /// <summary>
  31. /// S8-RESPONSIBILITY-POOL-1:「谁可以被选为这条异常的复核人」的<b>唯一</b>判定实现。
  32. ///
  33. /// <para><b>三个条件同时成立才算候选</b>:
  34. /// <list type="number">
  35. /// <item><b>规则责任</b>:账号在该异常来源规则的<b>复核账号池</b>里;</item>
  36. /// <item><b>动作权限</b>:角色被授予 <c>EXCEPTION_VERIFY</c>(能真的点「通过 / 退回」);</item>
  37. /// <item><b>账号可用</b>:同租户 + 启用。</item>
  38. /// </list></para>
  39. ///
  40. /// <para><b>候选列表与提交校验必须同源</b>——这正是本类存在的理由。
  41. /// 两边各写一套判据,就会出现「页面能选、提交被拒」或更糟的
  42. /// 「页面选了甲、后端放行乙」。上一批 Gate 修的错位形态就是同一个病根。</para>
  43. ///
  44. /// <para><b>人工提报的异常没有来源规则</b>(<c>SourceRuleCode</c> 为空),不受复核池约束 ——
  45. /// 池是"谁负责这条规则",而它根本不来自任何规则。一刀切要求入池,
  46. /// 人工提报的异常将永远提交不了复核。此时候选 = 本租户内有审核权限的账号。</para>
  47. /// </summary>
  48. public interface IS8VerifierEligibility
  49. {
  50. /// <summary>判定某账号能否成为该异常的复核人。</summary>
  51. Task<S8VerifierEligibilityResult> CheckAsync(long tenantId, long verifierUserId, AdoS8Exception exception);
  52. /// <summary>列出该异常当前<b>全部</b>合格的复核人候选。与 <see cref="CheckAsync"/> 判据同源。</summary>
  53. Task<List<S8VerifierCandidateDto>> ListCandidatesAsync(long tenantId, AdoS8Exception exception);
  54. }
  55. /// <summary>正式实现。生产代码里只有这一份;接口只为测试留 seam。</summary>
  56. public sealed class S8VerifierEligibility : IS8VerifierEligibility, ITransient
  57. {
  58. private readonly IS8ExceptionActionAuthorizer _authorizer;
  59. private readonly IS8RuleResponsibilityReader _pools;
  60. private readonly IS8UserScopeValidator _userScope;
  61. private readonly SqlSugarRepository<SysUser> _userRep;
  62. public S8VerifierEligibility(
  63. IS8ExceptionActionAuthorizer authorizer,
  64. IS8RuleResponsibilityReader pools,
  65. IS8UserScopeValidator userScope,
  66. SqlSugarRepository<SysUser> userRep)
  67. {
  68. _authorizer = authorizer;
  69. _pools = pools;
  70. _userScope = userScope;
  71. _userRep = userRep;
  72. }
  73. public async Task<S8VerifierEligibilityResult> CheckAsync(long tenantId, long verifierUserId, AdoS8Exception exception)
  74. {
  75. // ① 账号可用性。跨租户与不存在共用同一文案,不泄漏存在性。
  76. var user = await _userScope.FindActiveUserAsync(tenantId, verifierUserId);
  77. if (user == null)
  78. return S8VerifierEligibilityResult.Deny(
  79. S8VerifierDenyReason.NotUsable, "所选账号不存在或已停用");
  80. // ② 规则责任。人工提报无来源规则,不受池约束(见接口注释)。
  81. var ruleCode = exception.SourceRuleCode;
  82. if (!string.IsNullOrWhiteSpace(ruleCode)
  83. && !await _pools.IsMemberAsync(tenantId, ruleCode!, S8ResponsibilityType.Reviewer, verifierUserId))
  84. {
  85. return S8VerifierEligibilityResult.Deny(
  86. S8VerifierDenyReason.NotInReviewerPool,
  87. $"{user.DisplayName} 不是该规则的复核人员,不能作为检验人。如需由其复核,请在规则的「责任分配」中将其加入复核账号池。");
  88. }
  89. // ③ 动作权限 —— 选出来的人要真的能点「检验通过 / 检验退回」。
  90. var auth = await _authorizer.AuthorizeAsync(tenantId, verifierUserId, S8ExceptionActionCode.Verify);
  91. if (!auth.Allowed)
  92. return S8VerifierEligibilityResult.Deny(
  93. S8VerifierDenyReason.NoActionPermission,
  94. $"{user.DisplayName} 没有审核权限,不能作为检验人。请在「异常操作权限」中为其角色授予「审核」,或改选其他账号。");
  95. return S8VerifierEligibilityResult.Pass();
  96. }
  97. public async Task<List<S8VerifierCandidateDto>> ListCandidatesAsync(long tenantId, AdoS8Exception exception)
  98. {
  99. var ruleCode = exception.SourceRuleCode;
  100. // 候选来源:有来源规则 → 该规则的复核池;人工提报 → 本租户账号。
  101. IReadOnlyList<long> poolIds = string.IsNullOrWhiteSpace(ruleCode)
  102. ? await _userRep.AsQueryable().ClearFilter()
  103. .Where(u => u.TenantId == tenantId)
  104. .Take(S8UserScopeValidator.MaxHydrationKeys)
  105. .Select(u => u.Id)
  106. .ToListAsync()
  107. : await _pools.GetValidMemberIdsAsync(tenantId, ruleCode!, S8ResponsibilityType.Reviewer);
  108. if (poolIds.Count == 0) return new List<S8VerifierCandidateDto>();
  109. var usable = await _userScope.ValidateUsersAsync(tenantId, poolIds);
  110. var result = new List<S8VerifierCandidateDto>();
  111. foreach (var u in usable)
  112. {
  113. // 与 CheckAsync 的第 ③ 步同一个调用,不另写一套权限判据。
  114. var auth = await _authorizer.AuthorizeAsync(tenantId, u.UserId, S8ExceptionActionCode.Verify);
  115. if (!auth.Allowed) continue;
  116. result.Add(new S8VerifierCandidateDto
  117. {
  118. UserId = u.UserId,
  119. Account = u.Account,
  120. DisplayName = u.DisplayName,
  121. Label = $"{u.DisplayName} ({u.Account})",
  122. });
  123. }
  124. return result.OrderBy(x => x.DisplayName, StringComparer.CurrentCulture).ToList();
  125. }
  126. }