| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148 |
- using Admin.NET.Plugin.AiDOP.Const.S8;
- using Admin.NET.Plugin.AiDOP.Entity.S8;
- using Admin.NET.Plugin.AiDOP.Service.S8;
- namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8;
- /// <summary>复核人资格判定结果。拒绝时带原因码,让「没审核权限」与「不在复核池」可分辨。</summary>
- public sealed record S8VerifierEligibilityResult(bool Allowed, string? ReasonCode, string? Message)
- {
- public static S8VerifierEligibilityResult Pass() => new(true, null, null);
- public static S8VerifierEligibilityResult Deny(string reason, string message) => new(false, reason, message);
- }
- /// <summary>复核人被拒的原因码。</summary>
- public static class S8VerifierDenyReason
- {
- /// <summary>账号没有「审核」这个动作的权限(角色维度)。</summary>
- public const string NoActionPermission = "verifier_no_action_permission";
- /// <summary>账号不在这条规则的复核账号池里(责任维度)。</summary>
- public const string NotInReviewerPool = "verifier_not_in_reviewer_pool";
- /// <summary>账号不存在 / 已停用 / 不在本租户。</summary>
- public const string NotUsable = "verifier_not_usable";
- }
- /// <summary>复核人候选(提交复核时的下拉项)。</summary>
- public sealed class S8VerifierCandidateDto
- {
- public long UserId { get; set; }
- public string Account { get; set; } = string.Empty;
- public string DisplayName { get; set; } = string.Empty;
- /// <summary>页面展示文案:<c>RealName (Account)</c>。前后端共用同一份,避免两处各拼各的。</summary>
- public string Label { get; set; } = string.Empty;
- }
- /// <summary>
- /// S8-RESPONSIBILITY-POOL-1:「谁可以被选为这条异常的复核人」的<b>唯一</b>判定实现。
- ///
- /// <para><b>三个条件同时成立才算候选</b>:
- /// <list type="number">
- /// <item><b>规则责任</b>:账号在该异常来源规则的<b>复核账号池</b>里;</item>
- /// <item><b>动作权限</b>:角色被授予 <c>EXCEPTION_VERIFY</c>(能真的点「通过 / 退回」);</item>
- /// <item><b>账号可用</b>:同租户 + 启用。</item>
- /// </list></para>
- ///
- /// <para><b>候选列表与提交校验必须同源</b>——这正是本类存在的理由。
- /// 两边各写一套判据,就会出现「页面能选、提交被拒」或更糟的
- /// 「页面选了甲、后端放行乙」。上一批 Gate 修的错位形态就是同一个病根。</para>
- ///
- /// <para><b>人工提报的异常没有来源规则</b>(<c>SourceRuleCode</c> 为空),不受复核池约束 ——
- /// 池是"谁负责这条规则",而它根本不来自任何规则。一刀切要求入池,
- /// 人工提报的异常将永远提交不了复核。此时候选 = 本租户内有审核权限的账号。</para>
- /// </summary>
- public interface IS8VerifierEligibility
- {
- /// <summary>判定某账号能否成为该异常的复核人。</summary>
- Task<S8VerifierEligibilityResult> CheckAsync(long tenantId, long verifierUserId, AdoS8Exception exception);
- /// <summary>列出该异常当前<b>全部</b>合格的复核人候选。与 <see cref="CheckAsync"/> 判据同源。</summary>
- Task<List<S8VerifierCandidateDto>> ListCandidatesAsync(long tenantId, AdoS8Exception exception);
- }
- /// <summary>正式实现。生产代码里只有这一份;接口只为测试留 seam。</summary>
- public sealed class S8VerifierEligibility : IS8VerifierEligibility, ITransient
- {
- private readonly IS8ExceptionActionAuthorizer _authorizer;
- private readonly IS8RuleResponsibilityReader _pools;
- private readonly IS8UserScopeValidator _userScope;
- private readonly SqlSugarRepository<SysUser> _userRep;
- public S8VerifierEligibility(
- IS8ExceptionActionAuthorizer authorizer,
- IS8RuleResponsibilityReader pools,
- IS8UserScopeValidator userScope,
- SqlSugarRepository<SysUser> userRep)
- {
- _authorizer = authorizer;
- _pools = pools;
- _userScope = userScope;
- _userRep = userRep;
- }
- public async Task<S8VerifierEligibilityResult> CheckAsync(long tenantId, long verifierUserId, AdoS8Exception exception)
- {
- // ① 账号可用性。跨租户与不存在共用同一文案,不泄漏存在性。
- var user = await _userScope.FindActiveUserAsync(tenantId, verifierUserId);
- if (user == null)
- return S8VerifierEligibilityResult.Deny(
- S8VerifierDenyReason.NotUsable, "所选账号不存在或已停用");
- // ② 规则责任。人工提报无来源规则,不受池约束(见接口注释)。
- var ruleCode = exception.SourceRuleCode;
- if (!string.IsNullOrWhiteSpace(ruleCode)
- && !await _pools.IsMemberAsync(tenantId, ruleCode!, S8ResponsibilityType.Reviewer, verifierUserId))
- {
- return S8VerifierEligibilityResult.Deny(
- S8VerifierDenyReason.NotInReviewerPool,
- $"{user.DisplayName} 不是该规则的复核人员,不能作为检验人。如需由其复核,请在规则的「责任分配」中将其加入复核账号池。");
- }
- // ③ 动作权限 —— 选出来的人要真的能点「检验通过 / 检验退回」。
- var auth = await _authorizer.AuthorizeAsync(tenantId, verifierUserId, S8ExceptionActionCode.Verify);
- if (!auth.Allowed)
- return S8VerifierEligibilityResult.Deny(
- S8VerifierDenyReason.NoActionPermission,
- $"{user.DisplayName} 没有审核权限,不能作为检验人。请在「异常操作权限」中为其角色授予「审核」,或改选其他账号。");
- return S8VerifierEligibilityResult.Pass();
- }
- public async Task<List<S8VerifierCandidateDto>> ListCandidatesAsync(long tenantId, AdoS8Exception exception)
- {
- var ruleCode = exception.SourceRuleCode;
- // 候选来源:有来源规则 → 该规则的复核池;人工提报 → 本租户账号。
- IReadOnlyList<long> poolIds = string.IsNullOrWhiteSpace(ruleCode)
- ? await _userRep.AsQueryable().ClearFilter()
- .Where(u => u.TenantId == tenantId)
- .Take(S8UserScopeValidator.MaxHydrationKeys)
- .Select(u => u.Id)
- .ToListAsync()
- : await _pools.GetValidMemberIdsAsync(tenantId, ruleCode!, S8ResponsibilityType.Reviewer);
- if (poolIds.Count == 0) return new List<S8VerifierCandidateDto>();
- var usable = await _userScope.ValidateUsersAsync(tenantId, poolIds);
- var result = new List<S8VerifierCandidateDto>();
- foreach (var u in usable)
- {
- // 与 CheckAsync 的第 ③ 步同一个调用,不另写一套权限判据。
- var auth = await _authorizer.AuthorizeAsync(tenantId, u.UserId, S8ExceptionActionCode.Verify);
- if (!auth.Allowed) continue;
- result.Add(new S8VerifierCandidateDto
- {
- UserId = u.UserId,
- Account = u.Account,
- DisplayName = u.DisplayName,
- Label = $"{u.DisplayName} ({u.Account})",
- });
- }
- return result.OrderBy(x => x.DisplayName, StringComparer.CurrentCulture).ToList();
- }
- }
|