diff --git a/Document/11_SystemTodo.md b/Document/11_SystemTodo.md index 076085d..dac5cff 100644 --- a/Document/11_SystemTodo.md +++ b/Document/11_SystemTodo.md @@ -28,6 +28,13 @@ ## 4. 安全与合规 - [ ] RBAC 权限、租户隔离、用户/权限洞察 API 完整演示并在 Swagger 中提供示例。 + - [ ] 现状梳理:租户解析/过滤已具备(TenantResolutionMiddleware、TenantAwareDbContext),JWT 已写入 roles/permissions/tenant_id(JwtTokenService),PermissionAuthorize 已在 Admin API 使用,CurrentUserProfile 含角色/权限/租户;但仅有内嵌 string[] 权限存储,无角色/权限表与洞察查询,Swagger 缺少示例与多租户示例。 + - [ ] 差距与步骤: + - [ ] 增加权限/租户洞察查询(按用户、按租户分页)并确保带 tenant 过滤(TenantAwareDbContext 或 Dapper 参数化)。 + - [ ] 输出可读的角色/权限列表(基于现有种子/配置的只读查询)。 + - [ ] 为洞察接口和 /auth/profile 增加 Swagger 示例,包含 tenant_id、roles、permissions,展示 Bearer 示例与租户 Header 示例。 + - [ ] 若用 Dapper 读侧,SQL 必须参数化并显式过滤 tenant_id。 + - [ ] 计划顺序:Step A 设计应用层洞察 DTO/Query;Step B Admin API 只读端点(Authorize/PermissionAuthorize);Step C Swagger 示例扩展;Step D 校验租户过滤与忽略路径配置。 - [ ] 登录/刷新流程增加 IP 校验、租户隔离、验证码/频率限制。 - [ ] 登录/权限/敏感操作日志可追溯,提供查询接口或 Kibana Saved Search。 - [ ] Secret Store/KeyVault/KMS 管理敏感配置,禁止密钥写入 Git/数据库明文。 diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs index b406c49..72f23f4 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs @@ -55,6 +55,29 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr /// /// 获取当前用户信息 /// + /// + /// 示例: + /// + /// GET /api/admin/v1/auth/profile + /// Header: Authorization: Bearer <JWT> + /// 响应: + /// { + /// "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" + /// } + /// } + /// + /// [HttpGet("profile")] [PermissionAuthorize("identity:profile:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -70,4 +93,41 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr var profile = await authService.GetProfileAsync(userId, cancellationToken); return ApiResponse.Ok(profile); } + + /// + /// 查询指定用户的角色与权限概览(当前租户范围)。 + /// + /// + /// 示例: + /// + /// GET /api/admin/v1/auth/permissions/900123456789012346 + /// Header: Authorization: Bearer <JWT> + /// 响应: + /// { + /// "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" + /// } + /// } + /// + /// + [HttpGet("permissions/{userId:long}")] + [PermissionAuthorize("identity:permission:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> GetUserPermissions(long userId, CancellationToken cancellationToken) + { + var result = await authService.GetUserPermissionsAsync(userId, cancellationToken); + return result is null + ? ApiResponse.Error(ErrorCodes.NotFound, "用户不存在或不属于当前租户") + : ApiResponse.Ok(result); + } } diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/UserPermissionsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/UserPermissionsController.cs new file mode 100644 index 0000000..328499a --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/UserPermissionsController.cs @@ -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; + +/// +/// 用户权限洞察接口。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/users/permissions")] +public sealed class UserPermissionsController(IAdminAuthService authService) : BaseApiController +{ + /// + /// 分页查询当前租户用户的角色与权限概览。 + /// + /// + /// 示例: + /// + /// GET /api/admin/v1/users/permissions?keyword=ops&page=1&pageSize=20&sortBy=createdAt&sortDescending=true + /// Header: Authorization: Bearer <JWT> + /// 响应: + /// { + /// "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 + /// } + /// } + /// + /// + [HttpGet] + [PermissionAuthorize("identity:permission:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Search( + [FromQuery] SearchUserPermissionsQuery query, + CancellationToken cancellationToken) + { + var result = await authService.SearchUserPermissionsAsync( + query.Keyword, + query.Page, + query.PageSize, + query.SortBy, + query.SortDescending, + cancellationToken); + + return ApiResponse>.Ok(result); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs index 28510e3..d1cd0a7 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs @@ -2,6 +2,7 @@ using System; using System.Threading; using System.Threading.Tasks; using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Shared.Abstractions.Results; namespace TakeoutSaaS.Application.Identity.Abstractions; @@ -13,4 +14,6 @@ public interface IAdminAuthService Task LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default); Task RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default); Task GetProfileAsync(long userId, CancellationToken cancellationToken = default); + Task GetUserPermissionsAsync(long userId, CancellationToken cancellationToken = default); + Task> SearchUserPermissionsAsync(string? keyword, int page, int pageSize, string? sortBy, bool sortDescending, CancellationToken cancellationToken = default); } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserPermissionDto.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserPermissionDto.cs new file mode 100644 index 0000000..f3393ad --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserPermissionDto.cs @@ -0,0 +1,54 @@ +using System; +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 用户权限概览 DTO。 +/// +public sealed class UserPermissionDto +{ + /// + /// 用户 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long UserId { get; init; } + + /// + /// 租户 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 商户 ID(雪花,序列化为字符串,可空)。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? MerchantId { get; init; } + + /// + /// 登录账号。 + /// + public string Account { get; init; } = string.Empty; + + /// + /// 展示名称。 + /// + public string DisplayName { get; init; } = string.Empty; + + /// + /// 角色集合。 + /// + public string[] Roles { get; init; } = Array.Empty(); + + /// + /// 权限集合。 + /// + public string[] Permissions { get; init; } = Array.Empty(); + + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetUserPermissionsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetUserPermissionsQueryHandler.cs new file mode 100644 index 0000000..cdc727b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetUserPermissionsQueryHandler.cs @@ -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; + +/// +/// 按用户 ID 获取权限概览处理器。 +/// +public sealed class GetUserPermissionsQueryHandler( + IIdentityUserRepository identityUserRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + private readonly IIdentityUserRepository _identityUserRepository = identityUserRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task 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 + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchUserPermissionsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchUserPermissionsQueryHandler.cs new file mode 100644 index 0000000..adfe198 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchUserPermissionsQueryHandler.cs @@ -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; + +/// +/// 租户用户权限分页查询处理器。 +/// +public sealed class SearchUserPermissionsQueryHandler( + IIdentityUserRepository identityUserRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IIdentityUserRepository _identityUserRepository = identityUserRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task> 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(items, request.Page, request.PageSize, users.Count); + } + + private static IOrderedEnumerable SortUsers( + IReadOnlyCollection 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) + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/GetUserPermissionsQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/GetUserPermissionsQuery.cs new file mode 100644 index 0000000..84b11d2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/GetUserPermissionsQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Queries; + +/// +/// 按用户 ID 获取角色/权限概览。 +/// +public sealed class GetUserPermissionsQuery : IRequest +{ + /// + /// 用户 ID(雪花)。 + /// + public long UserId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchUserPermissionsQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchUserPermissionsQuery.cs new file mode 100644 index 0000000..1a6d557 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchUserPermissionsQuery.cs @@ -0,0 +1,36 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.Identity.Queries; + +/// +/// 按租户分页查询用户的角色/权限概览。 +/// +public sealed class SearchUserPermissionsQuery : IRequest> +{ + /// + /// 关键字(账号或展示名称)。 + /// + public string? Keyword { get; init; } + + /// + /// 页码,从 1 开始。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; + + /// + /// 排序字段(account/displayName/createdAt)。 + /// + public string? SortBy { get; init; } + + /// + /// 是否倒序。 + /// + public bool SortDescending { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs index 42418a4..e7b91fb 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs @@ -5,6 +5,8 @@ using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Domain.Identity.Repositories; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Abstractions.Tenancy; namespace TakeoutSaaS.Application.Identity.Services; @@ -15,8 +17,11 @@ public sealed class AdminAuthService( IIdentityUserRepository userRepository, IPasswordHasher passwordHasher, IJwtTokenService jwtTokenService, - IRefreshTokenStore refreshTokenStore) : IAdminAuthService + IRefreshTokenStore refreshTokenStore, + ITenantProvider tenantProvider) : IAdminAuthService { + private readonly ITenantProvider _tenantProvider = tenantProvider; + /// /// 管理后台登录:验证账号密码并生成令牌。 /// @@ -85,6 +90,78 @@ public sealed class AdminAuthService( return BuildProfile(user); } + /// + /// 获取指定用户的权限概览(校验当前租户)。 + /// + public async Task 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 + }; + } + + /// + /// 按租户分页查询用户权限概览。 + /// + public async Task> 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(items, page, pageSize, users.Count); + } + private static CurrentUserProfile BuildProfile(IdentityUser user) => new() { diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs index cc74343..80d0a1e 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using TakeoutSaaS.Domain.Identity.Entities; @@ -19,4 +20,12 @@ public interface IIdentityUserRepository /// 根据 ID 获取后台用户。 /// Task FindByIdAsync(long userId, CancellationToken cancellationToken = default); + + /// + /// 按租户与关键字查询后台用户列表(仅读)。 + /// + /// 租户 ID。 + /// 可选关键字(账号/名称)。 + /// 取消标记。 + Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default); } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs index a355874..e1f657d 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; @@ -18,4 +19,19 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde public Task FindByIdAsync(long userId, CancellationToken cancellationToken = default) => dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); + + public async Task> 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); + } }