diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md index 5eaadc5..55128c6 100644 --- a/Document/12_BusinessTodo.md +++ b/Document/12_BusinessTodo.md @@ -8,8 +8,8 @@ - 已交付:`src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs` 暴露注册、详情、实名提交、审核、订阅创建/升降配、审核日志 8 个端点;对应命令/查询位于 `src/Application/TakeoutSaaS.Application/App/Tenants`,仓储实现 `EfTenantRepository`,并写入 `TenantAuditLog` 记录。Swagger 自动收录上述接口,满足 Phase1 租户管理要求。 - [x] 商家入驻 API:证照上传、合同管理、类目选择,驱动待审/审核/驳回/通过状态机,文件持久在 COS。 - 已交付:`src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs` 新增证照上传/审核、合同创建与状态更新、商户审核、审核日志、类目列表等 8 个端点;应用层新增 `AddMerchantDocumentCommand`、`CreateMerchantContractCommand`、`ReviewMerchantCommand` 等 Handler;`MerchantDocument/Contract/Audit` DTO 完整返回详情,文件 URL 仍通过 `/api/admin/v1/files/upload` 上 COS。仓储实现扩展 `EfMerchantRepository` 支持文档/合同/AuditLog 持久化,`TakeoutAppDbContext` 新增 `merchant_audit_logs` 表实现状态机追踪。 -- [ ] RBAC 模板:平台管理员、租户管理员、店长、店员四角色模板;API 可复制并允许租户自定义扩展。 - - 当前:`RolesController`/`PermissionsController` 已提供角色与权限 CRUD(`src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs:16-88`、`.../PermissionsController.cs:16-63`),但没有“模板复制”或按租户批量初始化的接口。 +- [x] RBAC 模板:平台管理员、租户管理员、店长、店员四角色模板;API 可复制并允许租户自定义扩展。 + - 已交付:新增模板目录 `RoleTemplateProvider`(`src/Application/TakeoutSaaS.Application/Identity/Templates`),提供四个预置角色与权限定义;应用层新增模板列表/详情查询、复制与租户批量初始化命令(Handlers 位于 `src/Application/TakeoutSaaS.Application/Identity/Handlers`)。管理端 `RolesController` 暴露模板列表、详情、按模板复制、批量初始化端点(`src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs`),复制时自动补齐缺失权限并保留租户自定义授权。 - [ ] 配额与套餐:TenantPackage CRUD、订阅/续费/配额校验(门店/账号/短信/配送单量),超额返回 409 并记录 TenantQuotaUsage。 - 当前:领域层已有 `TenantPackage`/`TenantSubscription` 等实体(`src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPackage.cs:5-48`),数据库模型也同步生成,但 Admin API/应用层未暴露任何 CRUD 或配额校验逻辑。 - [ ] 租户运营面板:欠费/到期告警、账单列表、公告通知接口,支持已读状态并在 Admin UI 展示。 diff --git a/Document/API边界与自检清单.md b/Document/API边界与自检清单.md index d1fa76d..57bf98e 100644 --- a/Document/API边界与自检清单.md +++ b/Document/API边界与自检清单.md @@ -15,6 +15,7 @@ 3. DTO 是否按管理口径,未暴露用户端字段? 4. 是否使用参数化/AsNoTracking/投影,避免 N+1? 5. 路由和 Swagger 示例是否含租户/权限说明? +- **自检记录**:RolesController 新增模板列表/详情/复制/初始化端点,均已套用 `[Authorize]` + `PermissionAuthorize`、仅调用 CQRS/DTO,依赖租户头隔离。 ## 2. UserApi(C 端用户) - **面向对象**:App/H5 普通用户。 diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs index a9f7148..850e5df 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using MediatR; using Microsoft.AspNetCore.Authorization; @@ -20,6 +21,78 @@ namespace TakeoutSaaS.AdminApi.Controllers; [Route("api/admin/v{version:apiVersion}/roles")] public sealed class RolesController(IMediator mediator) : BaseApiController { + /// + /// 获取预置角色模板列表。 + /// + /// + /// 示例:GET /api/admin/v1/roles/templates + /// + [HttpGet("templates")] + [PermissionAuthorize("identity:role:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ListTemplates(CancellationToken cancellationToken) + { + var result = await mediator.Send(new ListRoleTemplatesQuery(), cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 获取单个角色模板详情。 + /// + /// + /// 示例:GET /api/admin/v1/roles/templates/tenant-admin + /// + [HttpGet("templates/{templateCode}")] + [PermissionAuthorize("identity:role:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> GetTemplate(string templateCode, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetRoleTemplateQuery { TemplateCode = templateCode }, cancellationToken); + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "角色模板不存在") + : ApiResponse.Ok(result); + } + + /// + /// 按模板复制角色并绑定权限。 + /// + /// + /// 示例:POST /api/admin/v1/roles/templates/store-manager/copy + /// Body: { "roleName": "新区店长" } + /// + [HttpPost("templates/{templateCode}/copy")] + [PermissionAuthorize("identity:role:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CopyFromTemplate( + string templateCode, + [FromBody, Required] CopyRoleTemplateCommand command, + CancellationToken cancellationToken) + { + command = command with { TemplateCode = templateCode }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 为当前租户批量初始化预置角色模板。 + /// + /// + /// 示例:POST /api/admin/v1/roles/templates/init + /// Body: { "templateCodes": ["tenant-admin","store-manager","store-staff"] } + /// + [HttpPost("templates/init")] + [PermissionAuthorize("identity:role:create")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> InitializeTemplates( + [FromBody] InitializeRoleTemplatesCommand? command, + CancellationToken cancellationToken) + { + command ??= new InitializeRoleTemplatesCommand(); + var result = await mediator.Send(command, cancellationToken); + return ApiResponse>.Ok(result); + } + /// /// 分页查询角色。 /// diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRoleTemplateProvider.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRoleTemplateProvider.cs new file mode 100644 index 0000000..6f183ca --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRoleTemplateProvider.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using TakeoutSaaS.Application.Identity.Templates; + +namespace TakeoutSaaS.Application.Identity.Abstractions; + +/// +/// 角色模板提供者,用于获取预置模板定义。 +/// +public interface IRoleTemplateProvider +{ + /// + /// 获取全部角色模板定义。 + /// + /// 模板定义集合。 + IReadOnlyList GetTemplates(); + + /// + /// 根据模板编码查找模板。 + /// + /// 模板编码。 + /// 模板定义;不存在时返回 null。 + RoleTemplateDefinition? FindByCode(string templateCode); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/CopyRoleTemplateCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/CopyRoleTemplateCommand.cs new file mode 100644 index 0000000..efaa1a3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/CopyRoleTemplateCommand.cs @@ -0,0 +1,30 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 从预置模板复制角色并绑定权限。 +/// +public sealed record CopyRoleTemplateCommand : IRequest +{ + /// + /// 模板编码。 + /// + public string TemplateCode { get; init; } = string.Empty; + + /// + /// 复制后角色名称(为空则使用模板名称)。 + /// + public string? RoleName { get; init; } + + /// + /// 复制后角色编码(为空则使用模板编码)。 + /// + public string? RoleCode { get; init; } + + /// + /// 角色描述(为空则沿用模板描述)。 + /// + public string? Description { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/InitializeRoleTemplatesCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/InitializeRoleTemplatesCommand.cs new file mode 100644 index 0000000..dc1a583 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/InitializeRoleTemplatesCommand.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 批量为当前租户初始化角色模板。 +/// +public sealed record InitializeRoleTemplatesCommand : IRequest> +{ + /// + /// 需要初始化的模板编码列表(为空则全部)。 + /// + public IReadOnlyCollection? TemplateCodes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/PermissionTemplateDto.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/PermissionTemplateDto.cs new file mode 100644 index 0000000..e3e1f34 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/PermissionTemplateDto.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 权限模板 DTO。 +/// +public sealed record PermissionTemplateDto +{ + /// + /// 权限编码。 + /// + public string Code { get; init; } = string.Empty; + + /// + /// 权限名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 权限描述。 + /// + public string? Description { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleTemplateDto.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleTemplateDto.cs new file mode 100644 index 0000000..fc43f5b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleTemplateDto.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 角色模板 DTO。 +/// +public sealed record RoleTemplateDto +{ + /// + /// 模板编码。 + /// + public string TemplateCode { get; init; } = string.Empty; + + /// + /// 模板名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 模板描述。 + /// + public string? Description { get; init; } + + /// + /// 包含的权限定义。 + /// + public IReadOnlyList Permissions { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs index c5df667..f680ef7 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Services; +using TakeoutSaaS.Application.Identity.Templates; namespace TakeoutSaaS.Application.Identity.Extensions; @@ -17,6 +18,7 @@ public static class IdentityServiceCollectionExtensions public static IServiceCollection AddIdentityApplication(this IServiceCollection services, bool enableMiniSupport = false) { services.AddScoped(); + services.AddSingleton(); if (enableMiniSupport) { diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs new file mode 100644 index 0000000..d5da9be --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 角色模板复制处理器。 +/// +public sealed class CopyRoleTemplateCommandHandler( + IRoleTemplateProvider roleTemplateProvider, + IRoleRepository roleRepository, + IPermissionRepository permissionRepository, + IRolePermissionRepository rolePermissionRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(CopyRoleTemplateCommand request, CancellationToken cancellationToken) + { + var template = roleTemplateProvider.FindByCode(request.TemplateCode) + ?? throw new BusinessException(ErrorCodes.NotFound, $"角色模板 {request.TemplateCode} 不存在"); + + var tenantId = tenantProvider.GetCurrentTenantId(); + var roleCode = string.IsNullOrWhiteSpace(request.RoleCode) ? template.TemplateCode : request.RoleCode.Trim(); + var roleName = string.IsNullOrWhiteSpace(request.RoleName) ? template.Name : request.RoleName.Trim(); + var roleDescription = request.Description ?? template.Description; + + // 1. 准备或更新角色主体(幂等创建)。 + var role = await roleRepository.FindByCodeAsync(roleCode, tenantId, cancellationToken); + if (role is null) + { + role = new Role + { + TenantId = tenantId, + Name = roleName, + Code = roleCode, + Description = roleDescription + }; + await roleRepository.AddAsync(role, cancellationToken); + } + else + { + if (!string.IsNullOrWhiteSpace(request.RoleName)) + { + role.Name = roleName; + } + + if (request.Description is not null) + { + role.Description = roleDescription; + } + + await roleRepository.UpdateAsync(role, cancellationToken); + } + + // 2. 确保模板权限全部存在,不存在则按模板定义创建。 + var targetPermissionCodes = template.Permissions + .Select(permission => permission.Code) + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var existingPermissions = await permissionRepository.GetByCodesAsync(tenantId, targetPermissionCodes, cancellationToken); + var permissionMap = existingPermissions.ToDictionary(x => x.Code, StringComparer.OrdinalIgnoreCase); + + foreach (var permissionDefinition in template.Permissions) + { + if (permissionMap.ContainsKey(permissionDefinition.Code)) + { + continue; + } + + var permission = new Permission + { + TenantId = tenantId, + Name = permissionDefinition.Name, + Code = permissionDefinition.Code, + Description = permissionDefinition.Description + }; + + await permissionRepository.AddAsync(permission, cancellationToken); + permissionMap[permissionDefinition.Code] = permission; + } + + await roleRepository.SaveChangesAsync(cancellationToken); + + // 3. 绑定缺失的权限,保留租户自定义的已有授权。 + var rolePermissions = await rolePermissionRepository.GetByRoleIdsAsync(tenantId, new[] { role.Id }, cancellationToken); + var existingPermissionIds = rolePermissions + .Select(x => x.PermissionId) + .ToHashSet(); + + var targetPermissionIds = targetPermissionCodes + .Select(code => permissionMap[code].Id) + .ToHashSet(); + + var toAdd = targetPermissionIds.Except(existingPermissionIds).ToArray(); + if (toAdd.Length > 0) + { + var relations = toAdd.Select(permissionId => new RolePermission + { + TenantId = tenantId, + RoleId = role.Id, + PermissionId = permissionId + }); + + await rolePermissionRepository.AddRangeAsync(relations, cancellationToken); + } + + await rolePermissionRepository.SaveChangesAsync(cancellationToken); + + return new RoleDto + { + Id = role.Id, + TenantId = role.TenantId, + Name = role.Name, + Code = role.Code, + Description = role.Description + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs new file mode 100644 index 0000000..70206e7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Queries; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 角色模板详情查询处理器。 +/// +public sealed class GetRoleTemplateQueryHandler(IRoleTemplateProvider roleTemplateProvider) + : IRequestHandler +{ + /// + public Task Handle(GetRoleTemplateQuery request, CancellationToken cancellationToken) + { + var template = roleTemplateProvider.FindByCode(request.TemplateCode); + return Task.FromResult(template is null ? null : TemplateMapper.ToDto(template)); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/InitializeRoleTemplatesCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/InitializeRoleTemplatesCommandHandler.cs new file mode 100644 index 0000000..765819d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/InitializeRoleTemplatesCommandHandler.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 租户角色模板批量初始化处理器。 +/// +public sealed class InitializeRoleTemplatesCommandHandler( + IRoleTemplateProvider roleTemplateProvider, + IMediator mediator) + : IRequestHandler> +{ + /// + public async Task> Handle(InitializeRoleTemplatesCommand request, CancellationToken cancellationToken) + { + // 1. 解析需要初始化的模板编码,默认取全部预置模板。 + var requestedCodes = request.TemplateCodes? + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => code.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var targetCodes = requestedCodes?.Length > 0 + ? requestedCodes + : roleTemplateProvider.GetTemplates().Select(template => template.TemplateCode).ToArray(); + + if (targetCodes.Length == 0) + { + return Array.Empty(); + } + + // 2. 逐个复制模板,幂等写入角色与权限。 + var roles = new List(targetCodes.Length); + foreach (var templateCode in targetCodes) + { + var template = roleTemplateProvider.FindByCode(templateCode) + ?? throw new BusinessException(ErrorCodes.NotFound, $"角色模板 {templateCode} 不存在"); + + var role = await mediator.Send(new CopyRoleTemplateCommand + { + TemplateCode = template.TemplateCode + }, cancellationToken); + + roles.Add(role); + } + + return roles; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs new file mode 100644 index 0000000..011029a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs @@ -0,0 +1,26 @@ +using System; +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Queries; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 角色模板列表查询处理器。 +/// +public sealed class ListRoleTemplatesQueryHandler(IRoleTemplateProvider roleTemplateProvider) + : IRequestHandler> +{ + /// + public Task> Handle(ListRoleTemplatesQuery request, CancellationToken cancellationToken) + { + var templates = roleTemplateProvider.GetTemplates() + .OrderBy(template => template.TemplateCode, StringComparer.OrdinalIgnoreCase) + .Select(TemplateMapper.ToDto) + .ToArray(); + + return Task.FromResult>(templates); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/TemplateMapper.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/TemplateMapper.cs new file mode 100644 index 0000000..47b9670 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/TemplateMapper.cs @@ -0,0 +1,37 @@ +using System.Linq; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Templates; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 模板 DTO 映射工具。 +/// +internal static class TemplateMapper +{ + /// + /// 将角色模板定义映射为 DTO。 + /// + /// 角色模板定义。 + /// 模板 DTO。 + public static RoleTemplateDto ToDto(RoleTemplateDefinition definition) + { + return new RoleTemplateDto + { + TemplateCode = definition.TemplateCode, + Name = definition.Name, + Description = definition.Description, + Permissions = definition.Permissions.Select(ToDto).ToArray() + }; + } + + private static PermissionTemplateDto ToDto(PermissionTemplateDefinition definition) + { + return new PermissionTemplateDto + { + Code = definition.Code, + Name = definition.Name, + Description = definition.Description + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/GetRoleTemplateQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/GetRoleTemplateQuery.cs new file mode 100644 index 0000000..b282312 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/GetRoleTemplateQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Queries; + +/// +/// 获取单个角色模板详情。 +/// +public sealed record GetRoleTemplateQuery : IRequest +{ + /// + /// 模板编码。 + /// + public string TemplateCode { get; init; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/ListRoleTemplatesQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/ListRoleTemplatesQuery.cs new file mode 100644 index 0000000..cf25f1e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/ListRoleTemplatesQuery.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Queries; + +/// +/// 查询角色模板列表。 +/// +public sealed record ListRoleTemplatesQuery : IRequest>; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Templates/PermissionTemplateDefinition.cs b/src/Application/TakeoutSaaS.Application/Identity/Templates/PermissionTemplateDefinition.cs new file mode 100644 index 0000000..2bb4a46 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Templates/PermissionTemplateDefinition.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Application.Identity.Templates; + +/// +/// 权限模板定义。 +/// +public sealed record PermissionTemplateDefinition +{ + /// + /// 权限编码(唯一键)。 + /// + public required string Code { get; init; } + + /// + /// 权限名称。 + /// + public required string Name { get; init; } + + /// + /// 权限描述。 + /// + public string? Description { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateDefinition.cs b/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateDefinition.cs new file mode 100644 index 0000000..6c4f3cc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateDefinition.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace TakeoutSaaS.Application.Identity.Templates; + +/// +/// 角色模板定义。 +/// +public sealed record RoleTemplateDefinition +{ + /// + /// 模板编码(唯一键)。 + /// + public required string TemplateCode { get; init; } + + /// + /// 角色名称。 + /// + public required string Name { get; init; } + + /// + /// 角色描述。 + /// + public string? Description { get; init; } + + /// + /// 模板绑定的权限集合。 + /// + public IReadOnlyList Permissions { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateProvider.cs b/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateProvider.cs new file mode 100644 index 0000000..e492fd0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateProvider.cs @@ -0,0 +1,480 @@ +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Linq; +using TakeoutSaaS.Application.Identity.Abstractions; + +namespace TakeoutSaaS.Application.Identity.Templates; + +/// +/// 预置角色模板提供者。 +/// +public sealed class RoleTemplateProvider : IRoleTemplateProvider +{ + private static readonly FrozenDictionary PermissionDefinitions = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["identity:role:read"] = new() + { + Code = "identity:role:read", + Name = "角色查询", + Description = "查看当前租户的角色列表与详情。" + }, + ["identity:role:create"] = new() + { + Code = "identity:role:create", + Name = "角色创建", + Description = "在当前租户内创建新角色。" + }, + ["identity:role:update"] = new() + { + Code = "identity:role:update", + Name = "角色更新", + Description = "修改角色名称与描述。" + }, + ["identity:role:delete"] = new() + { + Code = "identity:role:delete", + Name = "角色删除", + Description = "删除租户内的角色记录。" + }, + ["identity:role:bind-permission"] = new() + { + Code = "identity:role:bind-permission", + Name = "角色权限绑定", + Description = "为角色绑定或调整权限集合。" + }, + ["identity:permission:read"] = new() + { + Code = "identity:permission:read", + Name = "权限查询", + Description = "查看权限列表与详情。" + }, + ["identity:permission:create"] = new() + { + Code = "identity:permission:create", + Name = "权限创建", + Description = "创建新的权限定义。" + }, + ["identity:permission:update"] = new() + { + Code = "identity:permission:update", + Name = "权限更新", + Description = "修改权限名称或描述。" + }, + ["identity:permission:delete"] = new() + { + Code = "identity:permission:delete", + Name = "权限删除", + Description = "删除租户内的权限记录。" + }, + ["identity:profile:read"] = new() + { + Code = "identity:profile:read", + Name = "个人信息查看", + Description = "查看当前登录人的账号与权限信息。" + }, + ["tenant:create"] = new() + { + Code = "tenant:create", + Name = "租户创建", + Description = "创建新的租户记录。" + }, + ["tenant:read"] = new() + { + Code = "tenant:read", + Name = "租户查询", + Description = "查看租户列表与详情。" + }, + ["tenant:review"] = new() + { + Code = "tenant:review", + Name = "租户审核", + Description = "审核租户实名与资质信息。" + }, + ["tenant:subscription"] = new() + { + Code = "tenant:subscription", + Name = "租户订阅管理", + Description = "创建、续费或调整租户套餐订阅。" + }, + ["merchant:create"] = new() + { + Code = "merchant:create", + Name = "商户创建", + Description = "创建或提交商户入驻信息。" + }, + ["merchant:read"] = new() + { + Code = "merchant:read", + Name = "商户查看", + Description = "查看商户资料与审核状态。" + }, + ["merchant:update"] = new() + { + Code = "merchant:update", + Name = "商户更新", + Description = "更新商户资料或合同等信息。" + }, + ["merchant:delete"] = new() + { + Code = "merchant:delete", + Name = "商户删除", + Description = "删除或作废商户记录。" + }, + ["merchant:review"] = new() + { + Code = "merchant:review", + Name = "商户审核", + Description = "审核商户入驻、合同与证照。" + }, + ["merchant_category:read"] = new() + { + Code = "merchant_category:read", + Name = "类目查询", + Description = "查看经营类目列表。" + }, + ["merchant_category:create"] = new() + { + Code = "merchant_category:create", + Name = "类目创建", + Description = "新增经营类目。" + }, + ["merchant_category:update"] = new() + { + Code = "merchant_category:update", + Name = "类目更新", + Description = "调整经营类目名称或顺序。" + }, + ["merchant_category:delete"] = new() + { + Code = "merchant_category:delete", + Name = "类目删除", + Description = "删除经营类目。" + }, + ["store:create"] = new() + { + Code = "store:create", + Name = "门店创建", + Description = "创建新的门店记录。" + }, + ["store:read"] = new() + { + Code = "store:read", + Name = "门店查看", + Description = "查看门店列表与详情。" + }, + ["store:update"] = new() + { + Code = "store:update", + Name = "门店更新", + Description = "更新门店资料与配置。" + }, + ["store:delete"] = new() + { + Code = "store:delete", + Name = "门店删除", + Description = "删除或停用门店。" + }, + ["product:create"] = new() + { + Code = "product:create", + Name = "商品创建", + Description = "创建商品、菜品或规格。" + }, + ["product:read"] = new() + { + Code = "product:read", + Name = "商品查看", + Description = "查看商品/菜品列表与详情。" + }, + ["product:update"] = new() + { + Code = "product:update", + Name = "商品更新", + Description = "更新商品、菜品或上下架状态。" + }, + ["product:delete"] = new() + { + Code = "product:delete", + Name = "商品删除", + Description = "删除商品或菜品。" + }, + ["order:create"] = new() + { + Code = "order:create", + Name = "订单创建", + Description = "创建订单或手工录单。" + }, + ["order:read"] = new() + { + Code = "order:read", + Name = "订单查看", + Description = "查看订单列表与详情。" + }, + ["order:update"] = new() + { + Code = "order:update", + Name = "订单更新", + Description = "修改订单状态或履约信息。" + }, + ["order:delete"] = new() + { + Code = "order:delete", + Name = "订单删除", + Description = "删除或作废订单。" + }, + ["delivery:create"] = new() + { + Code = "delivery:create", + Name = "配送创建", + Description = "创建或发起配送单。" + }, + ["delivery:read"] = new() + { + Code = "delivery:read", + Name = "配送查看", + Description = "查看配送订单与轨迹。" + }, + ["delivery:update"] = new() + { + Code = "delivery:update", + Name = "配送更新", + Description = "更新配送状态或骑手信息。" + }, + ["delivery:delete"] = new() + { + Code = "delivery:delete", + Name = "配送删除", + Description = "取消或删除配送单。" + }, + ["payment:create"] = new() + { + Code = "payment:create", + Name = "支付创建", + Description = "创建收款或支付记录。" + }, + ["payment:read"] = new() + { + Code = "payment:read", + Name = "支付查看", + Description = "查看支付、退款记录。" + }, + ["payment:update"] = new() + { + Code = "payment:update", + Name = "支付更新", + Description = "更新支付状态或补充信息。" + }, + ["payment:delete"] = new() + { + Code = "payment:delete", + Name = "支付删除", + Description = "删除或作废支付记录。" + }, + ["dictionary:group:read"] = new() + { + Code = "dictionary:group:read", + Name = "字典分组查询", + Description = "查看字典分组与明细。" + }, + ["dictionary:group:create"] = new() + { + Code = "dictionary:group:create", + Name = "字典分组创建", + Description = "新增字典分组。" + }, + ["dictionary:group:update"] = new() + { + Code = "dictionary:group:update", + Name = "字典分组更新", + Description = "修改字典分组信息。" + }, + ["dictionary:group:delete"] = new() + { + Code = "dictionary:group:delete", + Name = "字典分组删除", + Description = "删除字典分组。" + }, + ["dictionary:item:create"] = new() + { + Code = "dictionary:item:create", + Name = "字典项创建", + Description = "新增字典项。" + }, + ["dictionary:item:update"] = new() + { + Code = "dictionary:item:update", + Name = "字典项更新", + Description = "调整字典项内容。" + }, + ["dictionary:item:delete"] = new() + { + Code = "dictionary:item:delete", + Name = "字典项删除", + Description = "删除字典项。" + }, + ["system-parameter:create"] = new() + { + Code = "system-parameter:create", + Name = "系统参数创建", + Description = "新增系统参数配置。" + }, + ["system-parameter:read"] = new() + { + Code = "system-parameter:read", + Name = "系统参数查询", + Description = "查看系统参数列表与详情。" + }, + ["system-parameter:update"] = new() + { + Code = "system-parameter:update", + Name = "系统参数更新", + Description = "更新系统参数配置。" + }, + ["system-parameter:delete"] = new() + { + Code = "system-parameter:delete", + Name = "系统参数删除", + Description = "删除系统参数配置。" + } + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + private static readonly FrozenDictionary Templates = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["platform-admin"] = new() + { + TemplateCode = "platform-admin", + Name = "平台管理员", + Description = "平台全量权限,负责租户、商户、配置与运维。", + Permissions = BuildPermissions(PermissionDefinitions.Keys) + }, + ["tenant-admin"] = new() + { + TemplateCode = "tenant-admin", + Name = "租户管理员", + Description = "管理本租户的门店、商品、订单与团队权限。", + Permissions = BuildPermissions(new[] + { + "identity:profile:read", + "identity:role:read", + "identity:role:create", + "identity:role:update", + "identity:role:delete", + "identity:role:bind-permission", + "identity:permission:read", + "identity:permission:create", + "identity:permission:update", + "identity:permission:delete", + "tenant:read", + "tenant:subscription", + "merchant:read", + "merchant:update", + "merchant_category:read", + "merchant_category:create", + "merchant_category:update", + "merchant_category:delete", + "store:create", + "store:read", + "store:update", + "store:delete", + "product:create", + "product:read", + "product:update", + "product:delete", + "order:create", + "order:read", + "order:update", + "delivery:create", + "delivery:read", + "delivery:update", + "payment:create", + "payment:read", + "payment:update", + "dictionary:group:read", + "dictionary:group:create", + "dictionary:group:update", + "dictionary:group:delete", + "dictionary:item:create", + "dictionary:item:update", + "dictionary:item:delete", + "system-parameter:read" + }) + }, + ["store-manager"] = new() + { + TemplateCode = "store-manager", + Name = "店长", + Description = "负责门店日常运营、商品与订单管理。", + Permissions = BuildPermissions(new[] + { + "identity:profile:read", + "store:read", + "store:update", + "product:create", + "product:read", + "product:update", + "order:create", + "order:read", + "order:update", + "delivery:read", + "delivery:update", + "payment:read", + "payment:update", + "dictionary:group:read", + "dictionary:item:create", + "dictionary:item:update", + "dictionary:item:delete" + }) + }, + ["store-staff"] = new() + { + TemplateCode = "store-staff", + Name = "店员", + Description = "处理订单履约、配送跟踪与收款查询。", + Permissions = BuildPermissions(new[] + { + "identity:profile:read", + "store:read", + "product:read", + "order:read", + "order:update", + "delivery:read", + "payment:read" + }) + } + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + /// + public IReadOnlyList GetTemplates() + => Templates.Values.ToArray(); + + /// + public RoleTemplateDefinition? FindByCode(string templateCode) + { + if (string.IsNullOrWhiteSpace(templateCode)) + { + return null; + } + + return Templates.GetValueOrDefault(templateCode); + } + + private static IReadOnlyList BuildPermissions(IEnumerable codes) + { + return codes + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => code.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(code => PermissionDefinitions.TryGetValue(code, out var definition) + ? definition + : new PermissionTemplateDefinition + { + Code = code, + Name = code, + Description = "未在预置表中定义的权限,请补充描述。" + }) + .ToArray(); + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs index 2bf97a0..7f78dde 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs @@ -12,6 +12,7 @@ public interface IPermissionRepository { Task FindByIdAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default); Task FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default); + Task> GetByCodesAsync(long tenantId, IEnumerable codes, CancellationToken cancellationToken = default); Task> GetByIdsAsync(long tenantId, IEnumerable permissionIds, CancellationToken cancellationToken = default); Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default); Task AddAsync(Permission permission, CancellationToken cancellationToken = default); diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs index 6ace0ce..5ef9d8d 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs @@ -11,6 +11,7 @@ namespace TakeoutSaaS.Domain.Identity.Repositories; public interface IRolePermissionRepository { Task> GetByRoleIdsAsync(long tenantId, IEnumerable roleIds, CancellationToken cancellationToken = default); + Task AddRangeAsync(IEnumerable rolePermissions, CancellationToken cancellationToken = default); Task ReplaceRolePermissionsAsync(long tenantId, long roleId, IEnumerable permissionIds, CancellationToken cancellationToken = default); Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs index c07f15d..37b7a0d 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs @@ -1,3 +1,4 @@ +using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Domain.Identity.Repositories; @@ -15,6 +16,20 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi public Task FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default) => dbContext.Permissions.AsNoTracking().FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId, cancellationToken); + public Task> GetByCodesAsync(long tenantId, IEnumerable codes, CancellationToken cancellationToken = default) + { + var normalizedCodes = codes + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => code.Trim()) + .Distinct() + .ToArray(); + + return dbContext.Permissions.AsNoTracking() + .Where(x => x.TenantId == tenantId && normalizedCodes.Contains(x.Code)) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + public Task> GetByIdsAsync(long tenantId, IEnumerable permissionIds, CancellationToken cancellationToken = default) => dbContext.Permissions.AsNoTracking() .Where(x => x.TenantId == tenantId && permissionIds.Contains(x.Id)) diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs index 0409fff..30376da 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs @@ -1,3 +1,4 @@ +using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Domain.Identity.Repositories; @@ -15,6 +16,17 @@ public sealed class EfRolePermissionRepository(IdentityDbContext dbContext) : IR .ToListAsync(cancellationToken) .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + public async Task AddRangeAsync(IEnumerable rolePermissions, CancellationToken cancellationToken = default) + { + var toAdd = rolePermissions as RolePermission[] ?? rolePermissions.ToArray(); + if (toAdd.Length == 0) + { + return; + } + + await dbContext.RolePermissions.AddRangeAsync(toAdd, cancellationToken); + } + public async Task ReplaceRolePermissionsAsync(long tenantId, long roleId, IEnumerable permissionIds, CancellationToken cancellationToken = default) { var existing = await dbContext.RolePermissions