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