From 0bf909dc2b11dce94457100fcf66e0a18391cbf7 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 3 Jul 2026 12:19:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(rbac):=20=E6=96=B0=E5=A2=9E=E5=86=85?= =?UTF-8?q?=E9=83=A8=E7=94=A8=E6=88=B7=E5=88=97=E8=A1=A8=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=EF=BC=8C=E6=8E=92=E9=99=A4=E7=BA=AF=E5=AE=A2=E6=88=B7=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Dtos/Rbac/InternalUserListDtos.cs | 42 +++++ .../Rbac/InternalUserRepository.cs | 163 ++++++++++++++++++ .../Services/Rbac/OmsInternalUserService.cs | 38 ++++ .../DataPermApiIntegrationTests.cs | 3 +- 4 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 src/Oms.Application/Dtos/Rbac/InternalUserListDtos.cs create mode 100644 src/Oms.Application/Rbac/InternalUserRepository.cs create mode 100644 src/Oms.Application/Services/Rbac/OmsInternalUserService.cs diff --git a/src/Oms.Application/Dtos/Rbac/InternalUserListDtos.cs b/src/Oms.Application/Dtos/Rbac/InternalUserListDtos.cs new file mode 100644 index 0000000..2daa056 --- /dev/null +++ b/src/Oms.Application/Dtos/Rbac/InternalUserListDtos.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; + +namespace Oms.Application.Dtos.Rbac; + +public class InternalUserListItemDto +{ + public string Id { get; set; } = string.Empty; + + public string? Name { get; set; } + + public string? Account { get; set; } + + public string? Email { get; set; } + + public string? Avatar { get; set; } + + public string? OrganizationId { get; set; } + + public string? OrganizationName { get; set; } + + public DateTime? CreatedAt { get; set; } + + public List Roles { get; set; } = []; +} + +public class InternalUserRoleDto +{ + public string Id { get; set; } = string.Empty; + + public string? Name { get; set; } + + public string? Code { get; set; } +} + +public class InternalUserListResultDto +{ + [JsonPropertyName("total")] + public long Total { get; set; } + + [JsonPropertyName("items")] + public List Items { get; set; } = []; +} diff --git a/src/Oms.Application/Rbac/InternalUserRepository.cs b/src/Oms.Application/Rbac/InternalUserRepository.cs new file mode 100644 index 0000000..c12bcea --- /dev/null +++ b/src/Oms.Application/Rbac/InternalUserRepository.cs @@ -0,0 +1,163 @@ +using Fake.DependencyInjection; +using Fake.Rbac.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Oms.Application.Dtos.Rbac; + +namespace Oms.Application.Rbac; + +public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDependency +{ + private const string InternalUserFilter = """ + ( + NOT EXISTS (SELECT 1 FROM `user_role` ur0 WHERE ur0.`UserId` = u.`Id`) + OR EXISTS ( + SELECT 1 + FROM `user_role` ur + INNER JOIN `role` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 + WHERE ur.`UserId` = u.`Id` + AND r.`Code` <> 'Customer' + AND r.`Code` NOT LIKE 'CustomerSub\_%' + ) + ) + """; + + public async Task GetInternalUsersAsync( + string? keyword, + Guid? organizationId, + int page, + int pageSize, + CancellationToken cancellationToken = default) + { + var where = $"WHERE u.`IsDeleted` = 0 AND {InternalUserFilter}"; + var args = new List(); + + if (!string.IsNullOrWhiteSpace(keyword)) + { + var pattern = $"%{keyword.Trim()}%"; + where += $" AND (u.`Name` LIKE {{{args.Count}}} OR u.`Account` LIKE {{{args.Count + 1}}})"; + args.Add(pattern); + args.Add(pattern); + } + + if (organizationId.HasValue && organizationId.Value != Guid.Empty) + { + where += $" AND u.`OrganizationId` = {{{args.Count}}}"; + args.Add(organizationId.Value.ToString()); + } + + var countSql = $""" + SELECT COUNT(*) AS `Value` + FROM `user` u + {where} + """; + var total = await dbContext.Database.SqlQueryRaw(countSql, args.ToArray()) + .FirstAsync(cancellationToken); + + var offset = Math.Max(page - 1, 0) * pageSize; + var listSql = $""" + SELECT + u.`Id` AS `Id`, + u.`Name` AS `Name`, + u.`Account` AS `Account`, + u.`Email` AS `Email`, + u.`Avatar` AS `Avatar`, + u.`OrganizationId` AS `OrganizationId`, + o.`Name` AS `OrganizationName`, + u.`CreateTime` AS `CreatedAt` + FROM `user` u + LEFT JOIN `organization` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 + {where} + ORDER BY u.`CreateTime` DESC + LIMIT {pageSize} OFFSET {offset} + """; + + var rows = await dbContext.Database.SqlQueryRaw(listSql, args.ToArray()) + .ToListAsync(cancellationToken); + if (rows.Count == 0) + { + return new InternalUserListResultDto { Total = total, Items = [] }; + } + + var userIds = rows.Select(x => x.Id).Distinct().ToList(); + var roleMap = await LoadRolesByUserIdsAsync(userIds, cancellationToken); + var items = rows.Select(row => new InternalUserListItemDto + { + Id = row.Id.ToString(), + Name = row.Name, + Account = row.Account, + Email = row.Email, + Avatar = row.Avatar, + OrganizationId = row.OrganizationId?.ToString(), + OrganizationName = row.OrganizationName, + CreatedAt = row.CreatedAt, + Roles = roleMap.TryGetValue(row.Id, out var roles) ? roles : [] + }).ToList(); + + return new InternalUserListResultDto { Total = total, Items = items }; + } + + private async Task>> LoadRolesByUserIdsAsync( + IReadOnlyList userIds, + CancellationToken cancellationToken) + { + if (userIds.Count == 0) + { + return []; + } + + var inClause = string.Join(",", userIds.Select(x => $"'{x}'")); + var sql = $""" + SELECT + ur.`UserId` AS `UserId`, + r.`Id` AS `RoleId`, + r.`Name` AS `RoleName`, + r.`Code` AS `RoleCode` + FROM `user_role` ur + INNER JOIN `role` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 + WHERE ur.`UserId` IN ({inClause}) + ORDER BY r.`Name` + """; + + var roleRows = await dbContext.Database.SqlQueryRaw(sql).ToListAsync(cancellationToken); + return roleRows + .GroupBy(x => x.UserId) + .ToDictionary( + g => g.Key, + g => g.Select(x => new InternalUserRoleDto + { + Id = x.RoleId.ToString(), + Name = x.RoleName, + Code = x.RoleCode + }).ToList()); + } + + private sealed class InternalUserRow + { + public Guid Id { get; set; } + + public string? Name { get; set; } + + public string? Account { get; set; } + + public string? Email { get; set; } + + public string? Avatar { get; set; } + + public Guid? OrganizationId { get; set; } + + public string? OrganizationName { get; set; } + + public DateTime? CreatedAt { get; set; } + } + + private sealed class UserRoleRow + { + public Guid UserId { get; set; } + + public Guid RoleId { get; set; } + + public string? RoleName { get; set; } + + public string? RoleCode { get; set; } + } +} diff --git a/src/Oms.Application/Services/Rbac/OmsInternalUserService.cs b/src/Oms.Application/Services/Rbac/OmsInternalUserService.cs new file mode 100644 index 0000000..81c5d18 --- /dev/null +++ b/src/Oms.Application/Services/Rbac/OmsInternalUserService.cs @@ -0,0 +1,38 @@ +using Fake.Application; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Rbac; +using Oms.Application.Rbac; +using Oms.Application.Shared; + +namespace Oms.Application.Services.Rbac; + +[Authorize] +[ApiExplorerSettings(GroupName = "RBAC-User")] +public class OmsInternalUserService(InternalUserRepository repository) : ApplicationService +{ + /// + /// 内部用户分页列表(排除纯 Customer / CustomerSub 客户账号)。 + /// + [HttpGet("/rbac/user/internal-list")] + public Task GetInternalListAsync( + [FromQuery] string? keyword, + [FromQuery] string? organizationId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 30, + CancellationToken cancellationToken = default) + { + Guid? orgId = null; + if (!string.IsNullOrWhiteSpace(organizationId) && Guid.TryParse(organizationId.Trim(), out var parsedOrgId)) + { + orgId = parsedOrgId; + } + + return repository.GetInternalUsersAsync( + keyword, + orgId, + OmsDocHelper.NormalizePage(page), + OmsDocHelper.NormalizePageSize(pageSize), + cancellationToken); + } +} diff --git a/src/SimpleAdmin.Api.IntegrationTests/DataPermApiIntegrationTests.cs b/src/SimpleAdmin.Api.IntegrationTests/DataPermApiIntegrationTests.cs index a255108..1267fca 100644 --- a/src/SimpleAdmin.Api.IntegrationTests/DataPermApiIntegrationTests.cs +++ b/src/SimpleAdmin.Api.IntegrationTests/DataPermApiIntegrationTests.cs @@ -26,7 +26,8 @@ public class DataPermApiIntegrationTests "rbac/data-perm/field-mask/save", "rbac/data-perm/change-log/list", "rbac/data-perm/access-log/list", - "rbac/data-perm/invalidate-cache" + "rbac/data-perm/invalidate-cache", + "rbac/user/internal-list" ]; [Fact]