using Admin.NET.Core.Service; using Microsoft.Extensions.Logging; namespace Admin.NET.Plugin.AiDOP.Service.S8; /// /// S8 本地 Role → UserIds 解析器。 /// 不改 Admin.NET.Core;不新增 SysUserRoleService.GetUserIdListByRoleCode。 /// 在 S8 侧自行完成 RoleCode → SysRole.Id → UserIds 的解析。 /// /// 兼容策略(与 ado_s8_notification_layer.target_role_ids 现状一致:表当前 0 行, /// SeedData 同时存在 RoleCode 命名(ROLE_ORDER_PLANNER)和 SysRole.Code 实存值(S8_01)): /// - 纯数字 token → 视为 SysRole.Id /// - 非数字 token → 视为 SysRole.Code /// - 找不到的 token:仅 LogWarning,不抛异常。 /// public class S8RoleResolver : ITransient { private readonly SqlSugarRepository _sysRoleRep; private readonly SysUserRoleService _sysUserRoleService; private readonly ILogger _logger; public S8RoleResolver( SqlSugarRepository sysRoleRep, SysUserRoleService sysUserRoleService, ILogger logger) { _sysRoleRep = sysRoleRep; _sysUserRoleService = sysUserRoleService; _logger = logger; } /// /// 解析 RoleCode / RoleId 混合 token 列表为去重 UserId 列表。 /// /// S8-ROLE-TENANT-SCOPE-1(P0 SECURITY):租户必须由调用方显式传入。 /// 原实现只按 Code / Id 匹配 SysRole,依赖平台的全局租户过滤器兜底; /// 但该过滤器只在有登录上下文时挂载(SqlSugarSetupApp.User 为空则不挂、超管直接跳过), /// 而 S8WatchSchedulerJobIServiceScopeFactory.CreateScope(),**没有 App.User**。 /// 又因 SysRole.Code 跨租户重复(每个业务角色每租户一份,实测 9 份), /// 后台派发时 "ROLE_PURCHASER" 会解析到全部租户的角色 → 跨租户通知投递。 /// /// 因此租户在此作为**显式安全边界**,不依赖任何隐式过滤: /// Code 路径与数字 RoleId 路径**都**必须满足 TenantId == tenantId, /// 后者可防止调用方(或被篡改的配置)传入他租户的 RoleId 绕过边界。 /// /// 安全边界租户。必须 > 0,否则直接返回空集合(绝不放行全量)。 /// RoleCode / RoleId 混合 token。 public async Task> ResolveUserIdsAsync(long tenantId, IEnumerable? roleTokens) { var result = new HashSet(); if (roleTokens == null) return result.ToList(); // 作用域缺失时宁可不通知,也不返回跨租户用户。 if (tenantId <= 0) { _logger.LogWarning("S8RoleResolver: tenantId<=0,拒绝解析角色(避免跨租户投递)"); return result.ToList(); } var requestedIds = new HashSet(); var codes = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var raw in roleTokens) { if (string.IsNullOrWhiteSpace(raw)) continue; var t = raw.Trim(); if (long.TryParse(t, out var rid) && rid > 0) requestedIds.Add(rid); else codes.Add(t); } // 经租户校验后的角色 Id,只有进入这里的才允许展开成用户。 var roleIds = new HashSet(); if (codes.Count > 0) { try { var codeList = codes.ToList(); var found = await _sysRoleRep.AsQueryable() .Where(r => codeList.Contains(r.Code) && r.TenantId == tenantId) .Select(r => new { r.Id, r.Code }) .ToListAsync(); var foundCodes = new HashSet(found.Select(f => f.Code ?? string.Empty), StringComparer.OrdinalIgnoreCase); foreach (var c in codes) { if (!foundCodes.Contains(c)) _logger.LogWarning("S8RoleResolver: RoleCode '{Code}' not found in SysRole for tenant {TenantId}", c, tenantId); } foreach (var f in found) roleIds.Add(f.Id); } catch (Exception ex) { _logger.LogWarning(ex, "S8RoleResolver: resolve RoleCode batch failed"); } } if (requestedIds.Count > 0) { try { // 数字 RoleId 同样过租户校验:他租户 Id 一律丢弃并告警,不静默放行。 var idList = requestedIds.ToList(); var ownedIds = await _sysRoleRep.AsQueryable() .Where(r => idList.Contains(r.Id) && r.TenantId == tenantId) .Select(r => r.Id) .ToListAsync(); var owned = new HashSet(ownedIds); foreach (var rid in requestedIds) { if (owned.Contains(rid)) roleIds.Add(rid); else _logger.LogWarning("S8RoleResolver: RoleId {RoleId} 不属于租户 {TenantId},已丢弃", rid, tenantId); } } catch (Exception ex) { _logger.LogWarning(ex, "S8RoleResolver: resolve RoleId batch failed"); } } foreach (var rid in roleIds) { try { var ids = await _sysUserRoleService.GetUserIdList(rid); foreach (var uid in ids) if (uid > 0) result.Add(uid); } catch (Exception ex) { _logger.LogWarning(ex, "S8RoleResolver: GetUserIdList(roleId={RoleId}) failed", rid); } } return result.ToList(); } /// /// 将 ado_s8_notification_layer.target_role_ids 这类 CSV 字段拆为 token 列表。 /// 兼容 ',' 和 ';' 分隔,trim + 去空。 /// public static List SplitTokens(string? csv) { if (string.IsNullOrWhiteSpace(csv)) return new List(); return csv.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); } }