feat: 实现租户角色管理 API

- 创建角色:POST /api/admin/v1/tenants/{tenantId}/roles
- 更新角色:PUT /api/admin/v1/tenants/{tenantId}/roles/{roleId}
- 获取角色权限:GET /api/admin/v1/tenants/{tenantId}/roles/{roleId}/permissions
- 更新角色权限:PUT /api/admin/v1/tenants/{tenantId}/roles/{roleId}/permissions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 07:48:35 +00:00
parent 8dff802248
commit 2dbd004ce0
9 changed files with 438 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity.Queries;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.AdminApi.Controllers;
/// <summary>
/// 租户角色管理。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/admin/v{version:apiVersion}/tenants/{tenantId:long}/roles")]
public sealed class TenantRolesController(IMediator mediator) : BaseApiController
{
/// <summary>
/// 创建租户角色。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="command">创建命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>创建后的角色。</returns>
[HttpPost]
[PermissionAuthorize("identity:role:create")]
[ProducesResponseType(typeof(ApiResponse<RoleDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<RoleDto>> Create(
long tenantId,
[FromBody, Required] CreateTenantRoleCommand command,
CancellationToken cancellationToken)
{
// 1. 绑定租户 ID
command = command with { TenantId = tenantId };
// 2. 执行创建
var result = await mediator.Send(command, cancellationToken);
// 3. 返回创建结果
return ApiResponse<RoleDto>.Ok(result);
}
/// <summary>
/// 更新租户角色。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="roleId">角色 ID。</param>
/// <param name="command">更新命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>更新后的角色。</returns>
[HttpPut("{roleId:long}")]
[PermissionAuthorize("identity:role:update")]
[ProducesResponseType(typeof(ApiResponse<RoleDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<RoleDto>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<RoleDto>> Update(
long tenantId,
long roleId,
[FromBody, Required] UpdateTenantRoleCommand command,
CancellationToken cancellationToken)
{
// 1. 绑定路由参数
command = command with { TenantId = tenantId, RoleId = roleId };
// 2. 执行更新
var result = await mediator.Send(command, cancellationToken);
// 3. 返回更新结果或 404
return result is null
? ApiResponse<RoleDto>.Error(StatusCodes.Status404NotFound, "角色不存在")
: ApiResponse<RoleDto>.Ok(result);
}
/// <summary>
/// 获取租户角色权限列表。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="roleId">角色 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>权限集合。</returns>
[HttpGet("{roleId:long}/permissions")]
[PermissionAuthorize("identity:role:read")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<PermissionDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<PermissionDto>>> GetPermissions(
long tenantId,
long roleId,
CancellationToken cancellationToken)
{
// 1. 构造查询
var query = new GetTenantRolePermissionsQuery { TenantId = tenantId, RoleId = roleId };
// 2. 执行查询
var result = await mediator.Send(query, cancellationToken);
// 3. 返回权限集合
return ApiResponse<IReadOnlyList<PermissionDto>>.Ok(result);
}
/// <summary>
/// 更新租户角色权限。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="roleId">角色 ID。</param>
/// <param name="command">更新命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>更新结果。</returns>
[HttpPut("{roleId:long}/permissions")]
[PermissionAuthorize("identity:role:update")]
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
public async Task<ApiResponse<bool>> UpdatePermissions(
long tenantId,
long roleId,
[FromBody, Required] UpdateTenantRolePermissionsCommand command,
CancellationToken cancellationToken)
{
// 1. 绑定路由参数
command = command with { TenantId = tenantId, RoleId = roleId };
// 2. 执行更新
var result = await mediator.Send(command, cancellationToken);
// 3. 返回更新结果
return ApiResponse<bool>.Ok(result);
}
}

View File

@@ -0,0 +1,30 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
namespace TakeoutSaaS.Application.Identity.Commands;
/// <summary>
/// 创建租户角色命令。
/// </summary>
public sealed record CreateTenantRoleCommand : IRequest<RoleDto>
{
/// <summary>
/// 租户 ID由路由绑定
/// </summary>
public long TenantId { get; init; }
/// <summary>
/// 角色编码(租户内唯一)。
/// </summary>
public string Code { get; init; } = string.Empty;
/// <summary>
/// 角色名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 角色描述。
/// </summary>
public string? Description { get; init; }
}

View File

@@ -0,0 +1,30 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
namespace TakeoutSaaS.Application.Identity.Commands;
/// <summary>
/// 更新租户角色命令。
/// </summary>
public sealed record UpdateTenantRoleCommand : IRequest<RoleDto?>
{
/// <summary>
/// 租户 ID由路由绑定
/// </summary>
public long TenantId { get; init; }
/// <summary>
/// 角色 ID由路由绑定
/// </summary>
public long RoleId { get; init; }
/// <summary>
/// 角色名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 角色描述。
/// </summary>
public string? Description { get; init; }
}

View File

@@ -0,0 +1,24 @@
using MediatR;
namespace TakeoutSaaS.Application.Identity.Commands;
/// <summary>
/// 更新租户角色权限命令。
/// </summary>
public sealed record UpdateTenantRolePermissionsCommand : IRequest<bool>
{
/// <summary>
/// 租户 ID由路由绑定
/// </summary>
public long TenantId { get; init; }
/// <summary>
/// 角色 ID由路由绑定
/// </summary>
public long RoleId { get; init; }
/// <summary>
/// 权限 ID 集合(替换模式)。
/// </summary>
public IReadOnlyCollection<long> PermissionIds { get; init; } = [];
}

View File

@@ -0,0 +1,59 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 创建租户角色命令处理器。
/// </summary>
public sealed class CreateTenantRoleCommandHandler(IRoleRepository roleRepository)
: IRequestHandler<CreateTenantRoleCommand, RoleDto>
{
/// <inheritdoc />
public async Task<RoleDto> Handle(CreateTenantRoleCommand request, CancellationToken cancellationToken)
{
// 1. 校验必填字段
if (string.IsNullOrWhiteSpace(request.Code) || string.IsNullOrWhiteSpace(request.Name))
{
throw new BusinessException(ErrorCodes.BadRequest, "角色编码与名称不能为空");
}
// 2. 检查编码唯一性(租户内)
var existing = await roleRepository.FindByCodeAsync(PortalType.Tenant, request.TenantId, request.Code, cancellationToken);
if (existing is not null)
{
throw new BusinessException(ErrorCodes.Conflict, $"角色编码 {request.Code} 已存在");
}
// 3. 构建角色实体
var role = new Role
{
Portal = PortalType.Tenant,
TenantId = request.TenantId,
Code = request.Code.Trim(),
Name = request.Name.Trim(),
Description = request.Description
};
// 4. 持久化
await roleRepository.AddAsync(role, cancellationToken);
await roleRepository.SaveChangesAsync(cancellationToken);
// 5. 返回 DTO
return new RoleDto
{
Portal = role.Portal,
Id = role.Id,
TenantId = role.TenantId,
Code = role.Code,
Name = role.Name,
Description = role.Description
};
}
}

View File

@@ -0,0 +1,59 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity.Queries;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 获取租户角色权限列表查询处理器。
/// </summary>
public sealed class GetTenantRolePermissionsQueryHandler(
IRoleRepository roleRepository,
IRolePermissionRepository rolePermissionRepository,
IPermissionRepository permissionRepository)
: IRequestHandler<GetTenantRolePermissionsQuery, IReadOnlyList<PermissionDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<PermissionDto>> Handle(GetTenantRolePermissionsQuery request, CancellationToken cancellationToken)
{
// 1. 校验角色存在
var role = await roleRepository.FindByIdAsync(PortalType.Tenant, request.TenantId, request.RoleId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "角色不存在");
// 2. 查询角色权限关系
var rolePermissions = await rolePermissionRepository.GetByRoleIdsAsync(
PortalType.Tenant,
request.TenantId,
[request.RoleId],
cancellationToken);
// 3. 提取权限 ID 集合
var permissionIds = rolePermissions.Select(rp => rp.PermissionId).Distinct().ToArray();
if (permissionIds.Length == 0)
{
return [];
}
// 4. 查询权限详情
var permissions = await permissionRepository.GetByIdsAsync(permissionIds, cancellationToken);
// 5. 映射 DTO
return permissions
.Select(p => new PermissionDto
{
Portal = p.Portal,
Id = p.Id,
ParentId = p.ParentId,
SortOrder = p.SortOrder,
Type = p.Type,
Name = p.Name,
Code = p.Code,
Description = p.Description
})
.ToArray();
}
}

View File

@@ -0,0 +1,52 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 更新租户角色命令处理器。
/// </summary>
public sealed class UpdateTenantRoleCommandHandler(IRoleRepository roleRepository)
: IRequestHandler<UpdateTenantRoleCommand, RoleDto?>
{
/// <inheritdoc />
public async Task<RoleDto?> Handle(UpdateTenantRoleCommand request, CancellationToken cancellationToken)
{
// 1. 校验必填字段
if (string.IsNullOrWhiteSpace(request.Name))
{
throw new BusinessException(ErrorCodes.BadRequest, "角色名称不能为空");
}
// 2. 查询目标角色
var role = await roleRepository.FindByIdAsync(PortalType.Tenant, request.TenantId, request.RoleId, cancellationToken);
if (role is null)
{
return null;
}
// 3. 更新字段
role.Name = request.Name.Trim();
role.Description = request.Description;
// 4. 持久化
await roleRepository.UpdateAsync(role, cancellationToken);
await roleRepository.SaveChangesAsync(cancellationToken);
// 5. 返回 DTO
return new RoleDto
{
Portal = role.Portal,
Id = role.Id,
TenantId = role.TenantId,
Code = role.Code,
Name = role.Name,
Description = role.Description
};
}
}

View File

@@ -0,0 +1,36 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 更新租户角色权限命令处理器。
/// </summary>
public sealed class UpdateTenantRolePermissionsCommandHandler(
IRoleRepository roleRepository,
IRolePermissionRepository rolePermissionRepository)
: IRequestHandler<UpdateTenantRolePermissionsCommand, bool>
{
/// <inheritdoc />
public async Task<bool> Handle(UpdateTenantRolePermissionsCommand request, CancellationToken cancellationToken)
{
// 1. 校验角色存在
var role = await roleRepository.FindByIdAsync(PortalType.Tenant, request.TenantId, request.RoleId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "角色不存在");
// 2. 替换角色权限(事务保证)
await rolePermissionRepository.ReplaceRolePermissionsAsync(
PortalType.Tenant,
request.TenantId,
request.RoleId,
request.PermissionIds,
cancellationToken);
// 3. 返回成功
return true;
}
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
namespace TakeoutSaaS.Application.Identity.Queries;
/// <summary>
/// 获取租户角色权限列表查询。
/// </summary>
public sealed record GetTenantRolePermissionsQuery : IRequest<IReadOnlyList<PermissionDto>>
{
/// <summary>
/// 租户 ID。
/// </summary>
public long TenantId { get; init; }
/// <summary>
/// 角色 ID。
/// </summary>
public long RoleId { get; init; }
}