feat(rbac): 新增内部用户列表接口,排除纯客户账号

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-03 12:19:14 +08:00
parent ad367998d2
commit 0bf909dc2b
4 changed files with 245 additions and 1 deletions

View File

@ -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<InternalUserRoleDto> 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<InternalUserListItemDto> Items { get; set; } = [];
}

View File

@ -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<InternalUserListResultDto> GetInternalUsersAsync(
string? keyword,
Guid? organizationId,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var where = $"WHERE u.`IsDeleted` = 0 AND {InternalUserFilter}";
var args = new List<object>();
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<long>(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<InternalUserRow>(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<Dictionary<Guid, List<InternalUserRoleDto>>> LoadRolesByUserIdsAsync(
IReadOnlyList<Guid> 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<UserRoleRow>(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; }
}
}

View File

@ -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
{
/// <summary>
/// 内部用户分页列表(排除纯 Customer / CustomerSub 客户账号)。
/// </summary>
[HttpGet("/rbac/user/internal-list")]
public Task<InternalUserListResultDto> 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);
}
}

View File

@ -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]