using Admin.NET.Core;
using Admin.NET.Plugin.AiDOP.Const.S8;
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 S8ActionRoleRowDto
{
public string ActionCode { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string ApiHint { get; set; } = string.Empty;
public int OrderNo { get; set; }
public List RoleIds { get; set; } = new();
/// 未配置任何角色 —— 该动作在本租户对所有非超管账号关闭。前端必须显式提示。
public bool NotConfigured => RoleIds.Count == 0;
}
/// 可授权角色选项。
public sealed class S8AssignableRoleDto
{
public long Id { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public int UserCount { get; set; }
}
///
/// S8-ACTION-PERMISSION-1:异常动作权限矩阵的读写服务 + 启动期默认供给。
///
/// 默认值从哪来:从本租户既有的SysRole → SysRoleMenu → SysMenu.Permission
/// 授权语义推导 —— 原来在这个租户里谁能做这件事,迁移后还是谁能做。
/// 刻意不拍一套新默认:那会在管理员毫无察觉的情况下改变现网的可操作范围。
///
/// 跨租户角色一律不带入。真库实测 UATAdminA 通过别家租户的
/// ROLE_S6_IPQC_SUPERVISOR 拿到了 s8:exception:assign,
/// 而它自己的租户里没有任何角色有这个能力。推导时按 SysRole.TenantId == 目标租户
/// 过滤,所以这类越权不会被固化进新表 —— 迁移之后该账号会失去转派能力,
/// 这是修复的预期结果,不是回归。
///
/// 推导不出角色的动作保持空(fail closed),由管理员在页面上显式配置。
/// 不做「空就给个兜底角色」——那等于把刚修掉的越权换个形式放回来。
///
public class S8ExceptionActionRoleService : ITransient
{
private readonly SqlSugarRepository _rep;
private readonly IS8TenantRoleResolver _roleResolver;
private readonly UserManager _userManager;
private readonly ILogger _logger;
public S8ExceptionActionRoleService(
SqlSugarRepository rep,
IS8TenantRoleResolver roleResolver,
UserManager userManager,
ILogger logger)
{
_rep = rep;
_roleResolver = roleResolver;
_userManager = userManager;
_logger = logger;
}
// ────────────────────────────── 读 ──────────────────────────────
/// 权限矩阵:目录里的每个动作都出一行,没配角色的也要出现(前端要能看见"未配置")。
public async Task> GetMatrixAsync(S8TrustedScope scope)
{
var rows = await _rep.AsQueryable().ClearFilter()
.Where(x => x.TenantId == scope.TenantId)
.Select(x => new { x.ActionCode, x.RoleId })
.ToListAsync();
var byAction = rows
.GroupBy(x => x.ActionCode, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Select(x => x.RoleId).Distinct().ToList(), StringComparer.OrdinalIgnoreCase);
return S8ExceptionActionCatalog.All
.OrderBy(d => d.OrderNo)
.Select(d => new S8ActionRoleRowDto
{
ActionCode = d.Code,
DisplayName = d.DisplayName,
ApiHint = d.ApiHint,
OrderNo = d.OrderNo,
RoleIds = byAction.GetValueOrDefault(d.Code) ?? new List()
})
.ToList();
}
///
/// 可授权角色列表。
///
/// 不机械返回全部 SysRole:本租户角色动辄十几个,其中大量与 S8 无关
/// (报表、看板、其它模块)。把它们全塞进权限矩阵会让管理员在几十个复选框里找
/// 那三四个真正相关的,反而更容易配错。判据是该角色在本租户里已经承担 S8 或
/// 业务处理职责:持有任一 s8:* 能力,或属于 S8 认定的业务角色
/// ()。
///
/// 已被本表授权过的角色无条件包含,否则管理员会看到一条自己无法取消的授权。
///
public async Task> GetAssignableRolesAsync(S8TrustedScope scope)
{
var tenantRoles = await _rep.Context.Queryable().ClearFilter()
.Where(r => r.TenantId == scope.TenantId)
.Select(r => new { r.Id, r.Code, r.Name })
.ToListAsync();
if (tenantRoles.Count == 0) return new List();
var roleIds = tenantRoles.Select(r => r.Id).ToList();
// 已持有任一 s8:* 能力的角色
var s8CapableRoleIds = (await _rep.Context.Queryable().ClearFilter()
.InnerJoin((rm, m) => rm.MenuId == m.Id)
.Where((rm, m) => roleIds.Contains(rm.RoleId)
&& m.Type == MenuTypeEnum.Btn
&& m.Permission != null
&& m.Permission.StartsWith("s8:"))
.Select((rm, m) => rm.RoleId)
.Distinct()
.ToListAsync()).ToHashSet();
// 已被本表授权过的角色
var alreadyGranted = (await _rep.AsQueryable().ClearFilter()
.Where(x => x.TenantId == scope.TenantId)
.Select(x => x.RoleId)
.ToListAsync()).ToHashSet();
var businessCodes = S8CanonicalRole.BusinessRoleCodes.ToHashSet(StringComparer.OrdinalIgnoreCase);
var candidates = tenantRoles
.Where(r => s8CapableRoleIds.Contains(r.Id)
|| alreadyGranted.Contains(r.Id)
|| businessCodes.Contains(r.Code ?? string.Empty))
.ToList();
var memberCounts = (await _rep.Context.Queryable().ClearFilter()
.Where(ur => candidates.Select(c => c.Id).Contains(ur.RoleId))
.Select(ur => new { ur.RoleId, ur.UserId })
.ToListAsync())
.GroupBy(x => x.RoleId)
.ToDictionary(g => g.Key, g => g.Select(x => x.UserId).Distinct().Count());
return candidates
.Select(r => new S8AssignableRoleDto
{
Id = r.Id,
Code = r.Code ?? string.Empty,
Name = string.IsNullOrWhiteSpace(r.Name) ? (r.Code ?? string.Empty) : r.Name,
UserCount = memberCounts.GetValueOrDefault(r.Id)
})
.OrderBy(r => r.Code, StringComparer.Ordinal)
.ToList();
}
// ────────────────────────────── 写 ──────────────────────────────
///
/// 覆盖某个动作的授权角色(全量替换)。
/// 角色必须属于当前租户,否则拒绝 —— 这正是本批要堵的那个洞,不能从写入口再漏回去。
///
public async Task SetActionRolesAsync(S8TrustedScope scope, string actionCode, IEnumerable? roleIds)
{
if (!S8ExceptionActionCatalog.IsKnown(actionCode))
throw new S8BizException($"未知动作码:{actionCode}");
var wanted = (roleIds ?? Enumerable.Empty()).Where(x => x > 0).Distinct().ToList();
if (wanted.Count > 0)
{
var validIds = (await _rep.Context.Queryable().ClearFilter()
.Where(r => r.TenantId == scope.TenantId && wanted.Contains(r.Id))
.Select(r => r.Id)
.ToListAsync()).ToHashSet();
var invalid = wanted.Where(id => !validIds.Contains(id)).ToList();
if (invalid.Count > 0)
// 与人员校验同口径:跨租户与不存在共用同一文案,不泄漏他租户角色是否存在。
throw new S8BizException("所选角色不存在或不属于当前租户");
}
var existing = await _rep.AsQueryable().ClearFilter()
.Where(x => x.TenantId == scope.TenantId && x.ActionCode == actionCode)
.ToListAsync();
var existingIds = existing.Select(x => x.RoleId).ToHashSet();
var toRemove = existing.Where(x => !wanted.Contains(x.RoleId)).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 AdoS8ExceptionActionRole
{
TenantId = scope.TenantId,
ActionCode = actionCode,
RoleId = id,
CreatedAt = now,
CreatedBy = _userManager.Account
}).ToList()).ExecuteCommandAsync();
}
_logger.LogInformation(
"s8_action_role_updated tenant={Tenant} action={Action} added={Added} removed={Removed} total={Total}",
scope.TenantId, actionCode, toAdd.Count, toRemove.Count, wanted.Count);
}
/// 当前登录账号在本租户被允许的动作码(前端按钮可见性)。
public Task> GetMyAllowedActionsAsync(S8TrustedScope scope, IS8ExceptionActionAuthorizer authorizer) =>
authorizer.GetAllowedActionsAsync(scope.TenantId, _userManager.UserId);
}