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;
/// 复核人资格判定结果。拒绝时带原因码,让「没审核权限」与「不在复核池」可分辨。
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);
}
/// 复核人被拒的原因码。
public static class S8VerifierDenyReason
{
/// 账号没有「审核」这个动作的权限(角色维度)。
public const string NoActionPermission = "verifier_no_action_permission";
/// 账号不在这条规则的复核账号池里(责任维度)。
public const string NotInReviewerPool = "verifier_not_in_reviewer_pool";
/// 账号不存在 / 已停用 / 不在本租户。
public const string NotUsable = "verifier_not_usable";
}
/// 复核人候选(提交复核时的下拉项)。
public sealed class S8VerifierCandidateDto
{
public long UserId { get; set; }
public string Account { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
/// 页面展示文案:RealName (Account)。前后端共用同一份,避免两处各拼各的。
public string Label { get; set; } = string.Empty;
}
///
/// S8-RESPONSIBILITY-POOL-1:「谁可以被选为这条异常的复核人」的唯一判定实现。
///
/// 三个条件同时成立才算候选:
///
/// - 规则责任:账号在该异常来源规则的复核账号池里;
/// - 动作权限:角色被授予 EXCEPTION_VERIFY(能真的点「通过 / 退回」);
/// - 账号可用:同租户 + 启用。
///
///
/// 候选列表与提交校验必须同源——这正是本类存在的理由。
/// 两边各写一套判据,就会出现「页面能选、提交被拒」或更糟的
/// 「页面选了甲、后端放行乙」。上一批 Gate 修的错位形态就是同一个病根。
///
/// 人工提报的异常没有来源规则(SourceRuleCode 为空),不受复核池约束 ——
/// 池是"谁负责这条规则",而它根本不来自任何规则。一刀切要求入池,
/// 人工提报的异常将永远提交不了复核。此时候选 = 本租户内有审核权限的账号。
///
public interface IS8VerifierEligibility
{
/// 判定某账号能否成为该异常的复核人。
Task CheckAsync(long tenantId, long verifierUserId, AdoS8Exception exception);
/// 列出该异常当前全部合格的复核人候选。与 判据同源。
Task> ListCandidatesAsync(long tenantId, AdoS8Exception exception);
}
/// 正式实现。生产代码里只有这一份;接口只为测试留 seam。
public sealed class S8VerifierEligibility : IS8VerifierEligibility, ITransient
{
private readonly IS8ExceptionActionAuthorizer _authorizer;
private readonly IS8RuleResponsibilityReader _pools;
private readonly IS8UserScopeValidator _userScope;
private readonly SqlSugarRepository _userRep;
public S8VerifierEligibility(
IS8ExceptionActionAuthorizer authorizer,
IS8RuleResponsibilityReader pools,
IS8UserScopeValidator userScope,
SqlSugarRepository userRep)
{
_authorizer = authorizer;
_pools = pools;
_userScope = userScope;
_userRep = userRep;
}
public async Task 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> ListCandidatesAsync(long tenantId, AdoS8Exception exception)
{
var ruleCode = exception.SourceRuleCode;
// 候选来源:有来源规则 → 该规则的复核池;人工提报 → 本租户账号。
IReadOnlyList 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();
var usable = await _userScope.ValidateUsersAsync(tenantId, poolIds);
var result = new List();
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();
}
}