using Admin.NET.Core; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using Microsoft.Extensions.Logging; namespace Admin.NET.Plugin.AiDOP.Service.S8; /// 人员池成员(展示用)。 public sealed class S8HandlerUserDto { public long UserId { get; set; } public string Account { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty; /// 该账号当前是否仍可用(同租户 + 启用)。false = 历史配置已失效,前端需显式提示。 public bool Valid { get; set; } } /// /// 责任池的读侧 seam。就绪门禁、认领资格、可见性收敛、以及后续的通知收件人解析 /// 都只需要读 —— 让它们依赖这个窄接口而不是整个服务,既能在单测里替身, /// 也让「谁会写这张表」在类型层面收敛到配置入口一处。 /// public interface IS8RuleHandlerPoolReader { /// 池成员账号 Id(原样返回,含可能已失效的历史成员)。 Task> GetMemberIdsAsync(long tenantId, string ruleCode); /// 池成员(含展示信息与有效性标记)。失效成员照样返回、只标 Valid=false Task> GetMembersAsync(long tenantId, string ruleCode); /// 该账号是否属于这条规则的责任池(停用 / 跨租户成员一律不算数)。 Task IsMemberAsync(long tenantId, string ruleCode, long userId); /// 该账号在本租户负责的全部规则编码(供可见性过滤使用)。 Task> GetRuleCodesForUserAsync(long tenantId, long userId); /// 批量取多条规则的池成员(避免规则列表页 N+1)。 Task>> GetPoolsAsync(long tenantId, IEnumerable ruleCodes); } /// /// S8-HANDLER-POOL-1:规则处理账号池的唯一读写实现。 /// /// 它回答的是「哪些账号负责这条规则」,与「哪个角色能做认领这类动作」 /// ()是两个正交问题。 /// 最终允许 = 动作权限 ∩ 规则责任 ∩ 状态机。 /// /// 成员合法性复用 ,不另写一套判据: /// 池成员与认领人必须用同一把尺子,否则会出现「配得进池、认领时又被拒」这种 /// 管理员完全无法自查的状态。 /// public class S8RuleHandlerPoolService : IS8RuleHandlerPoolReader, ITransient { private readonly SqlSugarRepository _rep; private readonly IS8UserScopeValidator _userScope; private readonly UserManager _userManager; private readonly ILogger _logger; public S8RuleHandlerPoolService( SqlSugarRepository rep, IS8UserScopeValidator userScope, UserManager userManager, ILogger logger) { _rep = rep; _userScope = userScope; _userManager = userManager; _logger = logger; } // ────────────────────────────── 读 ────────────────────────────── /// 池成员的账号 Id(原样返回,含可能已失效的历史成员)。 public async Task> GetMemberIdsAsync(long tenantId, string ruleCode) { if (tenantId <= 0 || string.IsNullOrWhiteSpace(ruleCode)) return Array.Empty(); return await _rep.AsQueryable().ClearFilter() .Where(x => x.TenantId == tenantId && x.RuleCode == ruleCode) .Select(x => x.UserId) .ToListAsync(); } /// /// 池成员(含展示信息与有效性标记)。 /// /// 失效成员照样返回、只标 Valid=false,不静默过滤: /// 账号被停用或移出租户之后,管理员必须能在页面上看见"这里有一个人已经不算数了", /// 而不是眼看着池子无声地少了一个人。 /// public async Task> GetMembersAsync(long tenantId, string ruleCode) { var ids = await GetMemberIdsAsync(tenantId, ruleCode); if (ids.Count == 0) return new List(); var valid = (await _userScope.ValidateUsersAsync(tenantId, ids)).ToDictionary(u => u.UserId); return ids.Distinct().Select(id => valid.TryGetValue(id, out var u) ? new S8HandlerUserDto { UserId = id, Account = u.Account, DisplayName = u.DisplayName, Valid = true } : new S8HandlerUserDto { UserId = id, Account = string.Empty, DisplayName = "账号已停用或不在本租户", Valid = false }) .ToList(); } /// /// 该账号是否属于这条规则的责任池。停用 / 跨租户成员一律不算数 —— /// 判据与认领时完全同源,避免"配置里有他、运行时又不认"。 /// public async Task IsMemberAsync(long tenantId, string ruleCode, long userId) { if (tenantId <= 0 || userId <= 0 || string.IsNullOrWhiteSpace(ruleCode)) return false; var exists = await _rep.AsQueryable().ClearFilter() .Where(x => x.TenantId == tenantId && x.RuleCode == ruleCode && x.UserId == userId) .AnyAsync(); if (!exists) return false; return await _userScope.FindActiveUserAsync(tenantId, userId) != null; } /// 该账号在本租户负责的全部规则编码(供可见性过滤使用)。 public async Task> GetRuleCodesForUserAsync(long tenantId, long userId) { if (tenantId <= 0 || userId <= 0) return Array.Empty(); return await _rep.AsQueryable().ClearFilter() .Where(x => x.TenantId == tenantId && x.UserId == userId) .Select(x => x.RuleCode) .Distinct() .ToListAsync(); } /// 批量取多条规则的池成员(避免规则列表页 N+1)。 public async Task>> GetPoolsAsync(long tenantId, IEnumerable ruleCodes) { var codes = ruleCodes.Where(c => !string.IsNullOrWhiteSpace(c)).Distinct().ToList(); if (tenantId <= 0 || codes.Count == 0) return new Dictionary>(StringComparer.OrdinalIgnoreCase); var rows = await _rep.AsQueryable().ClearFilter() .Where(x => x.TenantId == tenantId && codes.Contains(x.RuleCode)) .Select(x => new { x.RuleCode, x.UserId }) .ToListAsync(); return rows.GroupBy(x => x.RuleCode, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.Select(x => x.UserId).Distinct().ToList(), StringComparer.OrdinalIgnoreCase); } // ────────────────────────────── 写 ────────────────────────────── /// /// 全量替换某条规则的处理账号池。 /// /// 成员必须是本租户的启用账号 —— 跨租户与不存在共用同一文案,不泄漏存在性。 /// 判据来自 ,与认领 / 转派 / 复检完全同源。 /// public async Task SetMembersAsync(S8TrustedScope scope, string ruleCode, IEnumerable? userIds) { if (string.IsNullOrWhiteSpace(ruleCode)) throw new S8BizException("规则编码不能为空"); var wanted = (userIds ?? Enumerable.Empty()).Where(x => x > 0).Distinct().ToList(); if (wanted.Count > 0) { var validated = await _userScope.ValidateUsersAsync(scope.TenantId, wanted); var validIds = validated.Select(u => u.UserId).ToHashSet(); if (wanted.Any(id => !validIds.Contains(id))) throw new S8BizException("所选账号不存在或已停用"); } var existing = await _rep.AsQueryable().ClearFilter() .Where(x => x.TenantId == scope.TenantId && x.RuleCode == ruleCode) .ToListAsync(); var existingIds = existing.Select(x => x.UserId).ToHashSet(); var toRemove = existing.Where(x => !wanted.Contains(x.UserId)).Select(x => x.Id).ToList(); var toAdd = wanted.Where(id => !existingIds.Contains(id)).ToList(); if (toRemove.Count > 0) await _rep.AsDeleteable().Where(x => toRemove.Contains(x.Id)).ExecuteCommandAsync(); if (toAdd.Count > 0) { var now = DateTime.Now; await _rep.AsInsertable(toAdd.Select(id => new AdoS8RuleHandlerUser { TenantId = scope.TenantId, RuleCode = ruleCode, UserId = id, CreatedAt = now, CreatedBy = _userManager.Account }).ToList()).ExecuteCommandAsync(); } _logger.LogInformation( "s8_rule_handler_pool_updated tenant={Tenant} rule={Rule} added={Added} removed={Removed} total={Total}", scope.TenantId, ruleCode, toAdd.Count, toRemove.Count, wanted.Count); } }