feat: 添加用户权限洞察查询与示例

This commit is contained in:
2025-12-02 15:49:04 +08:00
parent 5a4ce12d61
commit 8fbd40ecf2
12 changed files with 458 additions and 1 deletions

View File

@@ -28,6 +28,13 @@
## 4. 安全与合规 ## 4. 安全与合规
- [ ] RBAC 权限、租户隔离、用户/权限洞察 API 完整演示并在 Swagger 中提供示例。 - [ ] RBAC 权限、租户隔离、用户/权限洞察 API 完整演示并在 Swagger 中提供示例。
- [ ] 现状梳理:租户解析/过滤已具备TenantResolutionMiddleware、TenantAwareDbContextJWT 已写入 roles/permissions/tenant_idJwtTokenServicePermissionAuthorize 已在 Admin API 使用CurrentUserProfile 含角色/权限/租户;但仅有内嵌 string[] 权限存储,无角色/权限表与洞察查询Swagger 缺少示例与多租户示例。
- [ ] 差距与步骤:
- [ ] 增加权限/租户洞察查询(按用户、按租户分页)并确保带 tenant 过滤TenantAwareDbContext 或 Dapper 参数化)。
- [ ] 输出可读的角色/权限列表(基于现有种子/配置的只读查询)。
- [ ] 为洞察接口和 /auth/profile 增加 Swagger 示例,包含 tenant_id、roles、permissions展示 Bearer 示例与租户 Header 示例。
- [ ] 若用 Dapper 读侧SQL 必须参数化并显式过滤 tenant_id。
- [ ] 计划顺序Step A 设计应用层洞察 DTO/QueryStep B Admin API 只读端点Authorize/PermissionAuthorizeStep C Swagger 示例扩展Step D 校验租户过滤与忽略路径配置。
- [ ] 登录/刷新流程增加 IP 校验、租户隔离、验证码/频率限制。 - [ ] 登录/刷新流程增加 IP 校验、租户隔离、验证码/频率限制。
- [ ] 登录/权限/敏感操作日志可追溯,提供查询接口或 Kibana Saved Search。 - [ ] 登录/权限/敏感操作日志可追溯,提供查询接口或 Kibana Saved Search。
- [ ] Secret Store/KeyVault/KMS 管理敏感配置,禁止密钥写入 Git/数据库明文。 - [ ] Secret Store/KeyVault/KMS 管理敏感配置,禁止密钥写入 Git/数据库明文。

View File

@@ -55,6 +55,29 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr
/// <summary> /// <summary>
/// 获取当前用户信息 /// 获取当前用户信息
/// </summary> /// </summary>
/// <remarks>
/// 示例:
/// <code>
/// GET /api/admin/v1/auth/profile
/// Header: Authorization: Bearer &lt;JWT&gt;
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "message": "操作成功",
/// "data": {
/// "userId": "900123456789012345",
/// "account": "admin@tenant1",
/// "displayName": "租户管理员",
/// "tenantId": "100000000000000001",
/// "merchantId": null,
/// "roles": ["TenantAdmin"],
/// "permissions": ["identity:permission:read", "merchant:read", "order:read"],
/// "avatar": "https://cdn.example.com/avatar.png"
/// }
/// }
/// </code>
/// </remarks>
[HttpGet("profile")] [HttpGet("profile")]
[PermissionAuthorize("identity:profile:read")] [PermissionAuthorize("identity:profile:read")]
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status200OK)]
@@ -70,4 +93,41 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr
var profile = await authService.GetProfileAsync(userId, cancellationToken); var profile = await authService.GetProfileAsync(userId, cancellationToken);
return ApiResponse<CurrentUserProfile>.Ok(profile); return ApiResponse<CurrentUserProfile>.Ok(profile);
} }
/// <summary>
/// 查询指定用户的角色与权限概览(当前租户范围)。
/// </summary>
/// <remarks>
/// 示例:
/// <code>
/// GET /api/admin/v1/auth/permissions/900123456789012346
/// Header: Authorization: Bearer &lt;JWT&gt;
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "data": {
/// "userId": "900123456789012346",
/// "tenantId": "100000000000000001",
/// "merchantId": "200000000000000001",
/// "account": "ops.manager",
/// "displayName": "运营经理",
/// "roles": ["OpsManager", "Reporter"],
/// "permissions": ["delivery:read", "order:read", "payment:read"],
/// "createdAt": "2025-12-01T08:30:00Z"
/// }
/// }
/// </code>
/// </remarks>
[HttpGet("permissions/{userId:long}")]
[PermissionAuthorize("identity:permission:read")]
[ProducesResponseType(typeof(ApiResponse<UserPermissionDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<UserPermissionDto>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<UserPermissionDto>> GetUserPermissions(long userId, CancellationToken cancellationToken)
{
var result = await authService.GetUserPermissionsAsync(userId, cancellationToken);
return result is null
? ApiResponse<UserPermissionDto>.Error(ErrorCodes.NotFound, "用户不存在或不属于当前租户")
: ApiResponse<UserPermissionDto>.Ok(result);
}
} }

View File

@@ -0,0 +1,71 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity.Queries;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.AdminApi.Controllers;
/// <summary>
/// 用户权限洞察接口。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/admin/v{version:apiVersion}/users/permissions")]
public sealed class UserPermissionsController(IAdminAuthService authService) : BaseApiController
{
/// <summary>
/// 分页查询当前租户用户的角色与权限概览。
/// </summary>
/// <remarks>
/// 示例:
/// <code>
/// GET /api/admin/v1/users/permissions?keyword=ops&amp;page=1&amp;pageSize=20&amp;sortBy=createdAt&amp;sortDescending=true
/// Header: Authorization: Bearer &lt;JWT&gt;
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "data": {
/// "items": [
/// {
/// "userId": "900123456789012346",
/// "tenantId": "100000000000000001",
/// "merchantId": "200000000000000001",
/// "account": "ops.manager",
/// "displayName": "运营经理",
/// "roles": ["OpsManager", "Reporter"],
/// "permissions": ["delivery:read", "order:read", "payment:read"],
/// "createdAt": "2025-12-01T08:30:00Z"
/// }
/// ],
/// "page": 1,
/// "pageSize": 20,
/// "totalCount": 1,
/// "totalPages": 1
/// }
/// }
/// </code>
/// </remarks>
[HttpGet]
[PermissionAuthorize("identity:permission:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<UserPermissionDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<UserPermissionDto>>> Search(
[FromQuery] SearchUserPermissionsQuery query,
CancellationToken cancellationToken)
{
var result = await authService.SearchUserPermissionsAsync(
query.Keyword,
query.Page,
query.PageSize,
query.SortBy,
query.SortDescending,
cancellationToken);
return ApiResponse<PagedResult<UserPermissionDto>>.Ok(result);
}
}

View File

@@ -2,6 +2,7 @@ using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.Identity.Abstractions; namespace TakeoutSaaS.Application.Identity.Abstractions;
@@ -13,4 +14,6 @@ public interface IAdminAuthService
Task<TokenResponse> LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default); Task<TokenResponse> LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default);
Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default); Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default);
Task<CurrentUserProfile> GetProfileAsync(long userId, CancellationToken cancellationToken = default); Task<CurrentUserProfile> GetProfileAsync(long userId, CancellationToken cancellationToken = default);
Task<UserPermissionDto?> GetUserPermissionsAsync(long userId, CancellationToken cancellationToken = default);
Task<PagedResult<UserPermissionDto>> SearchUserPermissionsAsync(string? keyword, int page, int pageSize, string? sortBy, bool sortDescending, CancellationToken cancellationToken = default);
} }

View File

@@ -0,0 +1,54 @@
using System;
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.Identity.Contracts;
/// <summary>
/// 用户权限概览 DTO。
/// </summary>
public sealed class UserPermissionDto
{
/// <summary>
/// 用户 ID雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long UserId { get; init; }
/// <summary>
/// 租户 ID雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 商户 ID雪花序列化为字符串可空
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? MerchantId { get; init; }
/// <summary>
/// 登录账号。
/// </summary>
public string Account { get; init; } = string.Empty;
/// <summary>
/// 展示名称。
/// </summary>
public string DisplayName { get; init; } = string.Empty;
/// <summary>
/// 角色集合。
/// </summary>
public string[] Roles { get; init; } = Array.Empty<string>();
/// <summary>
/// 权限集合。
/// </summary>
public string[] Permissions { get; init; } = Array.Empty<string>();
/// <summary>
/// 创建时间UTC
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -0,0 +1,42 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity.Queries;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 按用户 ID 获取权限概览处理器。
/// </summary>
public sealed class GetUserPermissionsQueryHandler(
IIdentityUserRepository identityUserRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetUserPermissionsQuery, UserPermissionDto?>
{
private readonly IIdentityUserRepository _identityUserRepository = identityUserRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
/// <inheritdoc />
public async Task<UserPermissionDto?> Handle(GetUserPermissionsQuery request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var user = await _identityUserRepository.FindByIdAsync(request.UserId, cancellationToken);
if (user == null || user.TenantId != tenantId)
{
return null;
}
return new UserPermissionDto
{
UserId = user.Id,
TenantId = user.TenantId,
MerchantId = user.MerchantId,
Account = user.Account,
DisplayName = user.DisplayName,
Roles = user.Roles,
Permissions = user.Permissions,
CreatedAt = user.CreatedAt
};
}
}

View File

@@ -0,0 +1,67 @@
using System.Linq;
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity.Queries;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 租户用户权限分页查询处理器。
/// </summary>
public sealed class SearchUserPermissionsQueryHandler(
IIdentityUserRepository identityUserRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SearchUserPermissionsQuery, PagedResult<UserPermissionDto>>
{
private readonly IIdentityUserRepository _identityUserRepository = identityUserRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
/// <inheritdoc />
public async Task<PagedResult<UserPermissionDto>> Handle(SearchUserPermissionsQuery request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var users = await _identityUserRepository.SearchAsync(tenantId, request.Keyword, cancellationToken);
var sorted = SortUsers(users, request.SortBy, request.SortDescending);
var paged = sorted
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToList();
var items = paged.Select(user => new UserPermissionDto
{
UserId = user.Id,
TenantId = user.TenantId,
MerchantId = user.MerchantId,
Account = user.Account,
DisplayName = user.DisplayName,
Roles = user.Roles,
Permissions = user.Permissions,
CreatedAt = user.CreatedAt
}).ToList();
return new PagedResult<UserPermissionDto>(items, request.Page, request.PageSize, users.Count);
}
private static IOrderedEnumerable<Domain.Identity.Entities.IdentityUser> SortUsers(
IReadOnlyCollection<Domain.Identity.Entities.IdentityUser> users,
string? sortBy,
bool sortDescending)
{
return sortBy?.ToLowerInvariant() switch
{
"account" => sortDescending
? users.OrderByDescending(x => x.Account)
: users.OrderBy(x => x.Account),
"displayname" => sortDescending
? users.OrderByDescending(x => x.DisplayName)
: users.OrderBy(x => x.DisplayName),
_ => sortDescending
? users.OrderByDescending(x => x.CreatedAt)
: users.OrderBy(x => x.CreatedAt)
};
}
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
namespace TakeoutSaaS.Application.Identity.Queries;
/// <summary>
/// 按用户 ID 获取角色/权限概览。
/// </summary>
public sealed class GetUserPermissionsQuery : IRequest<UserPermissionDto?>
{
/// <summary>
/// 用户 ID雪花
/// </summary>
public long UserId { get; init; }
}

View File

@@ -0,0 +1,36 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.Identity.Queries;
/// <summary>
/// 按租户分页查询用户的角色/权限概览。
/// </summary>
public sealed class SearchUserPermissionsQuery : IRequest<PagedResult<UserPermissionDto>>
{
/// <summary>
/// 关键字(账号或展示名称)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 页码,从 1 开始。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
/// <summary>
/// 排序字段account/displayName/createdAt
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// 是否倒序。
/// </summary>
public bool SortDescending { get; init; } = true;
}

View File

@@ -5,6 +5,8 @@ using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Repositories; using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions; using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Services; namespace TakeoutSaaS.Application.Identity.Services;
@@ -15,8 +17,11 @@ public sealed class AdminAuthService(
IIdentityUserRepository userRepository, IIdentityUserRepository userRepository,
IPasswordHasher<IdentityUser> passwordHasher, IPasswordHasher<IdentityUser> passwordHasher,
IJwtTokenService jwtTokenService, IJwtTokenService jwtTokenService,
IRefreshTokenStore refreshTokenStore) : IAdminAuthService IRefreshTokenStore refreshTokenStore,
ITenantProvider tenantProvider) : IAdminAuthService
{ {
private readonly ITenantProvider _tenantProvider = tenantProvider;
/// <summary> /// <summary>
/// 管理后台登录:验证账号密码并生成令牌。 /// 管理后台登录:验证账号密码并生成令牌。
/// </summary> /// </summary>
@@ -85,6 +90,78 @@ public sealed class AdminAuthService(
return BuildProfile(user); return BuildProfile(user);
} }
/// <summary>
/// 获取指定用户的权限概览(校验当前租户)。
/// </summary>
public async Task<UserPermissionDto?> GetUserPermissionsAsync(long userId, CancellationToken cancellationToken = default)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var user = await userRepository.FindByIdAsync(userId, cancellationToken);
if (user == null || user.TenantId != tenantId)
{
return null;
}
return new UserPermissionDto
{
UserId = user.Id,
TenantId = user.TenantId,
MerchantId = user.MerchantId,
Account = user.Account,
DisplayName = user.DisplayName,
Roles = user.Roles,
Permissions = user.Permissions,
CreatedAt = user.CreatedAt
};
}
/// <summary>
/// 按租户分页查询用户权限概览。
/// </summary>
public async Task<PagedResult<UserPermissionDto>> SearchUserPermissionsAsync(
string? keyword,
int page,
int pageSize,
string? sortBy,
bool sortDescending,
CancellationToken cancellationToken = default)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var users = await userRepository.SearchAsync(tenantId, keyword, cancellationToken);
var sorted = sortBy?.ToLowerInvariant() switch
{
"account" => sortDescending
? users.OrderByDescending(x => x.Account)
: users.OrderBy(x => x.Account),
"displayname" => sortDescending
? users.OrderByDescending(x => x.DisplayName)
: users.OrderBy(x => x.DisplayName),
_ => sortDescending
? users.OrderByDescending(x => x.CreatedAt)
: users.OrderBy(x => x.CreatedAt)
};
var paged = sorted
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();
var items = paged.Select(user => new UserPermissionDto
{
UserId = user.Id,
TenantId = user.TenantId,
MerchantId = user.MerchantId,
Account = user.Account,
DisplayName = user.DisplayName,
Roles = user.Roles,
Permissions = user.Permissions,
CreatedAt = user.CreatedAt
}).ToList();
return new PagedResult<UserPermissionDto>(items, page, pageSize, users.Count);
}
private static CurrentUserProfile BuildProfile(IdentityUser user) private static CurrentUserProfile BuildProfile(IdentityUser user)
=> new() => new()
{ {

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Domain.Identity.Entities;
@@ -19,4 +20,12 @@ public interface IIdentityUserRepository
/// 根据 ID 获取后台用户。 /// 根据 ID 获取后台用户。
/// </summary> /// </summary>
Task<IdentityUser?> FindByIdAsync(long userId, CancellationToken cancellationToken = default); Task<IdentityUser?> FindByIdAsync(long userId, CancellationToken cancellationToken = default);
/// <summary>
/// 按租户与关键字查询后台用户列表(仅读)。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="keyword">可选关键字(账号/名称)。</param>
/// <param name="cancellationToken">取消标记。</param>
Task<IReadOnlyList<IdentityUser>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default);
} }

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -18,4 +19,19 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
public Task<IdentityUser?> FindByIdAsync(long userId, CancellationToken cancellationToken = default) public Task<IdentityUser?> FindByIdAsync(long userId, CancellationToken cancellationToken = default)
=> dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); => dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
public async Task<IReadOnlyList<IdentityUser>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
{
var query = dbContext.IdentityUsers
.AsNoTracking()
.Where(x => x.TenantId == tenantId);
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
query = query.Where(x => x.Account.Contains(normalized) || x.DisplayName.Contains(normalized));
}
return await query.ToListAsync(cancellationToken);
}
} }