diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantRolesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantRolesController.cs
new file mode 100644
index 0000000..4cc8315
--- /dev/null
+++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantRolesController.cs
@@ -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;
+
+///
+/// 租户角色管理。
+///
+[ApiVersion("1.0")]
+[Authorize]
+[Route("api/admin/v{version:apiVersion}/tenants/{tenantId:long}/roles")]
+public sealed class TenantRolesController(IMediator mediator) : BaseApiController
+{
+ ///
+ /// 创建租户角色。
+ ///
+ /// 租户 ID。
+ /// 创建命令。
+ /// 取消标记。
+ /// 创建后的角色。
+ [HttpPost]
+ [PermissionAuthorize("identity:role:create")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> 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.Ok(result);
+ }
+
+ ///
+ /// 更新租户角色。
+ ///
+ /// 租户 ID。
+ /// 角色 ID。
+ /// 更新命令。
+ /// 取消标记。
+ /// 更新后的角色。
+ [HttpPut("{roleId:long}")]
+ [PermissionAuthorize("identity:role:update")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
+ public async Task> 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.Error(StatusCodes.Status404NotFound, "角色不存在")
+ : ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 获取租户角色权限列表。
+ ///
+ /// 租户 ID。
+ /// 角色 ID。
+ /// 取消标记。
+ /// 权限集合。
+ [HttpGet("{roleId:long}/permissions")]
+ [PermissionAuthorize("identity:role:read")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> 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>.Ok(result);
+ }
+
+ ///
+ /// 更新租户角色权限。
+ ///
+ /// 租户 ID。
+ /// 角色 ID。
+ /// 更新命令。
+ /// 取消标记。
+ /// 更新结果。
+ [HttpPut("{roleId:long}/permissions")]
+ [PermissionAuthorize("identity:role:update")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> 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.Ok(result);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateTenantRoleCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateTenantRoleCommand.cs
new file mode 100644
index 0000000..8b02b65
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateTenantRoleCommand.cs
@@ -0,0 +1,30 @@
+using MediatR;
+using TakeoutSaaS.Application.Identity.Contracts;
+
+namespace TakeoutSaaS.Application.Identity.Commands;
+
+///
+/// 创建租户角色命令。
+///
+public sealed record CreateTenantRoleCommand : IRequest
+{
+ ///
+ /// 租户 ID(由路由绑定)。
+ ///
+ public long TenantId { get; init; }
+
+ ///
+ /// 角色编码(租户内唯一)。
+ ///
+ 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/Commands/UpdateTenantRoleCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateTenantRoleCommand.cs
new file mode 100644
index 0000000..540d8f7
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateTenantRoleCommand.cs
@@ -0,0 +1,30 @@
+using MediatR;
+using TakeoutSaaS.Application.Identity.Contracts;
+
+namespace TakeoutSaaS.Application.Identity.Commands;
+
+///
+/// 更新租户角色命令。
+///
+public sealed record UpdateTenantRoleCommand : IRequest
+{
+ ///
+ /// 租户 ID(由路由绑定)。
+ ///
+ public long TenantId { get; init; }
+
+ ///
+ /// 角色 ID(由路由绑定)。
+ ///
+ public long RoleId { get; init; }
+
+ ///
+ /// 角色名称。
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// 角色描述。
+ ///
+ public string? Description { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateTenantRolePermissionsCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateTenantRolePermissionsCommand.cs
new file mode 100644
index 0000000..7cb0287
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateTenantRolePermissionsCommand.cs
@@ -0,0 +1,24 @@
+using MediatR;
+
+namespace TakeoutSaaS.Application.Identity.Commands;
+
+///
+/// 更新租户角色权限命令。
+///
+public sealed record UpdateTenantRolePermissionsCommand : IRequest
+{
+ ///
+ /// 租户 ID(由路由绑定)。
+ ///
+ public long TenantId { get; init; }
+
+ ///
+ /// 角色 ID(由路由绑定)。
+ ///
+ public long RoleId { get; init; }
+
+ ///
+ /// 权限 ID 集合(替换模式)。
+ ///
+ public IReadOnlyCollection PermissionIds { get; init; } = [];
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateTenantRoleCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateTenantRoleCommandHandler.cs
new file mode 100644
index 0000000..160c29e
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateTenantRoleCommandHandler.cs
@@ -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;
+
+///
+/// 创建租户角色命令处理器。
+///
+public sealed class CreateTenantRoleCommandHandler(IRoleRepository roleRepository)
+ : IRequestHandler
+{
+ ///
+ public async Task 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
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetTenantRolePermissionsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetTenantRolePermissionsQueryHandler.cs
new file mode 100644
index 0000000..4a6d1ef
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetTenantRolePermissionsQueryHandler.cs
@@ -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;
+
+///
+/// 获取租户角色权限列表查询处理器。
+///
+public sealed class GetTenantRolePermissionsQueryHandler(
+ IRoleRepository roleRepository,
+ IRolePermissionRepository rolePermissionRepository,
+ IPermissionRepository permissionRepository)
+ : IRequestHandler>
+{
+ ///
+ public async Task> 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();
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateTenantRoleCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateTenantRoleCommandHandler.cs
new file mode 100644
index 0000000..0f6798c
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateTenantRoleCommandHandler.cs
@@ -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;
+
+///
+/// 更新租户角色命令处理器。
+///
+public sealed class UpdateTenantRoleCommandHandler(IRoleRepository roleRepository)
+ : IRequestHandler
+{
+ ///
+ public async Task 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
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateTenantRolePermissionsCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateTenantRolePermissionsCommandHandler.cs
new file mode 100644
index 0000000..920a7d6
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateTenantRolePermissionsCommandHandler.cs
@@ -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;
+
+///
+/// 更新租户角色权限命令处理器。
+///
+public sealed class UpdateTenantRolePermissionsCommandHandler(
+ IRoleRepository roleRepository,
+ IRolePermissionRepository rolePermissionRepository)
+ : IRequestHandler
+{
+ ///
+ public async Task 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;
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/GetTenantRolePermissionsQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/GetTenantRolePermissionsQuery.cs
new file mode 100644
index 0000000..521822b
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/GetTenantRolePermissionsQuery.cs
@@ -0,0 +1,20 @@
+using MediatR;
+using TakeoutSaaS.Application.Identity.Contracts;
+
+namespace TakeoutSaaS.Application.Identity.Queries;
+
+///
+/// 获取租户角色权限列表查询。
+///
+public sealed record GetTenantRolePermissionsQuery : IRequest>
+{
+ ///
+ /// 租户 ID。
+ ///
+ public long TenantId { get; init; }
+
+ ///
+ /// 角色 ID。
+ ///
+ public long RoleId { get; init; }
+}