From a1499fc1a18f46298f1e4a247922a67e09e53c1b Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Fri, 5 Dec 2025 21:16:07 +0800 Subject: [PATCH] feat: add admin menu management crud --- .../Controllers/MenusController.cs | 108 +++ .../Identity/Commands/CreateMenuCommand.cs | 26 + .../Identity/Commands/DeleteMenuCommand.cs | 11 + .../Identity/Commands/UpdateMenuCommand.cs | 27 + .../Identity/Contracts/MenuDefinitionDto.cs | 84 +++ .../Handlers/CreateMenuCommandHandler.cs | 51 ++ .../Handlers/DeleteMenuCommandHandler.cs | 29 + .../Handlers/ListMenusQueryHandler.cs | 32 + .../Handlers/MenuDetailQueryHandler.cs | 33 + .../Identity/Handlers/MenuMapper.cs | 101 +++ .../Handlers/UpdateMenuCommandHandler.cs | 52 ++ .../Identity/Queries/ListMenusQuery.cs | 12 + .../Identity/Queries/MenuDetailQuery.cs | 15 + .../Identity/Services/AdminAuthService.cs | 107 ++- .../Identity/Entities/MenuDefinition.cs | 79 +++ .../Identity/Repositories/IMenuRepository.cs | 39 + .../Extensions/ServiceCollectionExtensions.cs | 2 + .../Identity/Persistence/IdentityDbContext.cs | 28 + .../Identity/Repositories/EfMenuRepository.cs | 65 ++ ...51205131436_AddMenuDefinitions.Designer.cs | 667 ++++++++++++++++++ .../20251205131436_AddMenuDefinitions.cs | 62 ++ .../IdentityDbContextModelSnapshot.cs | 119 ++++ 22 files changed, 1747 insertions(+), 2 deletions(-) create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/MenusController.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Commands/CreateMenuCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteMenuCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateMenuCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Contracts/MenuDefinitionDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateMenuCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteMenuCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/ListMenusQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/MenuDetailQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/MenuMapper.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateMenuCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Queries/ListMenusQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Queries/MenuDetailQuery.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Identity/Entities/MenuDefinition.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMenuRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Repositories/EfMenuRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251205131436_AddMenuDefinitions.Designer.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251205131436_AddMenuDefinitions.cs diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/MenusController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/MenusController.cs new file mode 100644 index 0000000..cf3eb1a --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/MenusController.cs @@ -0,0 +1,108 @@ +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}/menus")] +public sealed class MenusController(IMediator mediator) : BaseApiController +{ + /// + /// 获取当前租户的菜单列表(平铺)。 + /// + [HttpGet] + [PermissionAuthorize("identity:menu:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> List(CancellationToken cancellationToken) + { + // 1. 查询菜单列表 + var result = await mediator.Send(new ListMenusQuery(), cancellationToken); + + // 2. 返回数据 + return ApiResponse>.Ok(result); + } + + /// + /// 获取菜单详情。 + /// + [HttpGet("{menuId:long}")] + [PermissionAuthorize("identity:menu:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Detail(long menuId, CancellationToken cancellationToken) + { + // 1. 查询详情 + var result = await mediator.Send(new MenuDetailQuery { Id = menuId }, cancellationToken); + + // 2. 返回或 404 + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "菜单不存在") + : ApiResponse.Ok(result); + } + + /// + /// 创建菜单。 + /// + [HttpPost] + [PermissionAuthorize("identity:menu:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create([FromBody, Required] CreateMenuCommand command, CancellationToken cancellationToken) + { + // 1. 创建菜单 + var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 + return ApiResponse.Ok(result); + } + + /// + /// 更新菜单。 + /// + [HttpPut("{menuId:long}")] + [PermissionAuthorize("identity:menu:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Update( + long menuId, + [FromBody, Required] UpdateMenuCommand command, + CancellationToken cancellationToken) + { + // 1. 绑定菜单 ID + command = command with { Id = menuId }; + + // 2. 执行更新 + var result = await mediator.Send(command, cancellationToken); + + // 3. 返回或 404 + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "菜单不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除菜单。 + /// + [HttpDelete("{menuId:long}")] + [PermissionAuthorize("identity:menu:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Delete(long menuId, CancellationToken cancellationToken) + { + // 1. 删除菜单 + var result = await mediator.Send(new DeleteMenuCommand { Id = menuId }, cancellationToken); + + // 2. 返回执行结果 + return ApiResponse.Ok(result); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateMenuCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateMenuCommand.cs new file mode 100644 index 0000000..1441c82 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateMenuCommand.cs @@ -0,0 +1,26 @@ +using MediatR; +using System.Collections.Generic; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 创建菜单命令。 +/// +public sealed record CreateMenuCommand : IRequest +{ + public long ParentId { get; init; } + public string Name { get; init; } = string.Empty; + public string Path { get; init; } = string.Empty; + public string Component { get; init; } = string.Empty; + public string Title { get; init; } = string.Empty; + public string? Icon { get; init; } + public bool IsIframe { get; init; } + public string? Link { get; init; } + public bool KeepAlive { get; init; } + public int SortOrder { get; init; } + public IReadOnlyCollection RequiredPermissions { get; init; } = []; + public IReadOnlyCollection MetaPermissions { get; init; } = []; + public IReadOnlyCollection MetaRoles { get; init; } = []; + public IReadOnlyCollection AuthList { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteMenuCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteMenuCommand.cs new file mode 100644 index 0000000..1c73c01 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteMenuCommand.cs @@ -0,0 +1,11 @@ +using MediatR; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 删除菜单命令。 +/// +public sealed record DeleteMenuCommand : IRequest +{ + public long Id { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateMenuCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateMenuCommand.cs new file mode 100644 index 0000000..470d722 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateMenuCommand.cs @@ -0,0 +1,27 @@ +using MediatR; +using System.Collections.Generic; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 更新菜单命令。 +/// +public sealed record UpdateMenuCommand : IRequest +{ + public long Id { get; init; } + public long ParentId { get; init; } + public string Name { get; init; } = string.Empty; + public string Path { get; init; } = string.Empty; + public string Component { get; init; } = string.Empty; + public string Title { get; init; } = string.Empty; + public string? Icon { get; init; } + public bool IsIframe { get; init; } + public string? Link { get; init; } + public bool KeepAlive { get; init; } + public int SortOrder { get; init; } + public IReadOnlyCollection RequiredPermissions { get; init; } = []; + public IReadOnlyCollection MetaPermissions { get; init; } = []; + public IReadOnlyCollection MetaRoles { get; init; } = []; + public IReadOnlyCollection AuthList { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/MenuDefinitionDto.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/MenuDefinitionDto.cs new file mode 100644 index 0000000..444ab3b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/MenuDefinitionDto.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; + +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 菜单定义 DTO。 +/// +public sealed record MenuDefinitionDto +{ + /// + /// 菜单 ID。 + /// + public long Id { get; init; } + + /// + /// 父级菜单 ID。 + /// + public long ParentId { get; init; } + + /// + /// 名称(路由 name)。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 路由路径。 + /// + public string Path { get; init; } = string.Empty; + + /// + /// 组件路径。 + /// + public string Component { get; init; } = string.Empty; + + /// + /// 标题。 + /// + public string Title { get; init; } = string.Empty; + + /// + /// 图标。 + /// + public string? Icon { get; init; } + + /// + /// 是否 iframe。 + /// + public bool IsIframe { get; init; } + + /// + /// 链接。 + /// + public string? Link { get; init; } + + /// + /// 是否缓存。 + /// + public bool KeepAlive { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } + + /// + /// 访问权限。 + /// + public IReadOnlyList RequiredPermissions { get; init; } = []; + + /// + /// Meta.permissions。 + /// + public IReadOnlyList MetaPermissions { get; init; } = []; + + /// + /// Meta.roles。 + /// + public IReadOnlyList MetaRoles { get; init; } = []; + + /// + /// 按钮权限列表。 + /// + public IReadOnlyList AuthList { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateMenuCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateMenuCommandHandler.cs new file mode 100644 index 0000000..b8b7316 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateMenuCommandHandler.cs @@ -0,0 +1,51 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 创建菜单处理器。 +/// +public sealed class CreateMenuCommandHandler( + IMenuRepository menuRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(CreateMenuCommand request, CancellationToken cancellationToken) + { + // 1. 构造实体 + var tenantId = tenantProvider.GetCurrentTenantId(); + var entity = new MenuDefinition + { + TenantId = tenantId, + ParentId = request.ParentId, + Name = request.Name.Trim(), + Path = request.Path.Trim(), + Component = request.Component.Trim(), + Title = request.Title.Trim(), + Icon = request.Icon?.Trim(), + IsIframe = request.IsIframe, + Link = string.IsNullOrWhiteSpace(request.Link) ? null : request.Link.Trim(), + KeepAlive = request.KeepAlive, + SortOrder = request.SortOrder, + RequiredPermissions = MenuMapper.JoinCodes(request.RequiredPermissions), + MetaPermissions = MenuMapper.JoinCodes(request.MetaPermissions), + MetaRoles = MenuMapper.JoinCodes(request.MetaRoles), + AuthListJson = request.AuthList.Count == 0 + ? null + : System.Text.Json.JsonSerializer.Serialize(request.AuthList) + }; + + // 2. 持久化 + await menuRepository.AddAsync(entity, cancellationToken); + await menuRepository.SaveChangesAsync(cancellationToken); + + // 3. 返回 DTO + return MenuMapper.ToDto(entity); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteMenuCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteMenuCommandHandler.cs new file mode 100644 index 0000000..d65a2c4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteMenuCommandHandler.cs @@ -0,0 +1,29 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 删除菜单处理器。 +/// +public sealed class DeleteMenuCommandHandler( + IMenuRepository menuRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(DeleteMenuCommand request, CancellationToken cancellationToken) + { + // 1. 删除目标及可能的孤儿由外层保证 + var tenantId = tenantProvider.GetCurrentTenantId(); + await menuRepository.DeleteAsync(request.Id, tenantId, cancellationToken); + + // 2. 持久化 + await menuRepository.SaveChangesAsync(cancellationToken); + + // 3. 返回执行结果 + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListMenusQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListMenusQueryHandler.cs new file mode 100644 index 0000000..08b14b6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListMenusQueryHandler.cs @@ -0,0 +1,32 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Queries; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 菜单列表查询处理器。 +/// +public sealed class ListMenusQueryHandler( + IMenuRepository menuRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> Handle(ListMenusQuery request, CancellationToken cancellationToken) + { + // 1. 获取租户 + var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 查询列表 + var entities = await menuRepository.GetByTenantAsync(tenantId, cancellationToken); + + // 3. 映射 DTO + var items = entities.Select(MenuMapper.ToDto).ToList(); + + // 4. 返回结果 + return items; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/MenuDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/MenuDetailQueryHandler.cs new file mode 100644 index 0000000..e681bc3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/MenuDetailQueryHandler.cs @@ -0,0 +1,33 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Queries; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 菜单详情查询处理器。 +/// +public sealed class MenuDetailQueryHandler( + IMenuRepository menuRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(MenuDetailQuery request, CancellationToken cancellationToken) + { + // 1. 获取租户 + var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 查询实体 + var entity = await menuRepository.FindByIdAsync(request.Id, tenantId, cancellationToken); + if (entity is null) + { + return null; + } + + // 3. 映射并返回 + return MenuMapper.ToDto(entity); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/MenuMapper.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/MenuMapper.cs new file mode 100644 index 0000000..6e20d99 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/MenuMapper.cs @@ -0,0 +1,101 @@ +using System.Text.Json; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Identity.Entities; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 菜单映射辅助。 +/// +internal static class MenuMapper +{ + public static MenuDefinitionDto ToDto(MenuDefinition entity) + { + // 1. 解析权限字段 + var requiredPermissions = SplitCodes(entity.RequiredPermissions); + var metaPermissions = SplitCodes(entity.MetaPermissions); + var metaRoles = SplitCodes(entity.MetaRoles); + + // 2. 解析按钮权限 + var authList = string.IsNullOrWhiteSpace(entity.AuthListJson) + ? [] + : JsonSerializer.Deserialize>(entity.AuthListJson) ?? []; + + // 3. 输出 DTO + return new MenuDefinitionDto + { + Id = entity.Id, + ParentId = entity.ParentId, + Name = entity.Name, + Path = entity.Path, + Component = entity.Component, + Title = entity.Title, + Icon = entity.Icon, + IsIframe = entity.IsIframe, + Link = entity.Link, + KeepAlive = entity.KeepAlive, + SortOrder = entity.SortOrder, + RequiredPermissions = requiredPermissions, + MetaPermissions = metaPermissions, + MetaRoles = metaRoles, + AuthList = authList + }; + } + + public static void FillEntity(MenuDefinition entity, MenuDefinitionDto dto) + { + // 1. 赋值基础字段 + entity.ParentId = dto.ParentId; + entity.Name = dto.Name; + entity.Path = dto.Path; + entity.Component = dto.Component; + entity.Title = dto.Title; + entity.Icon = dto.Icon; + entity.IsIframe = dto.IsIframe; + entity.Link = dto.Link; + entity.KeepAlive = dto.KeepAlive; + entity.SortOrder = dto.SortOrder; + + // 2. 权限与按钮 + entity.RequiredPermissions = JoinCodes(dto.RequiredPermissions); + entity.MetaPermissions = JoinCodes(dto.MetaPermissions); + entity.MetaRoles = JoinCodes(dto.MetaRoles); + entity.AuthListJson = dto.AuthList.Count == 0 + ? null + : JsonSerializer.Serialize(dto.AuthList); + } + + public static MenuDefinitionDto FromCommand(MenuDefinition? existing, long tenantId, string name, MenuDefinitionDto payload) + { + // 1. 构造实体 + var entity = existing ?? new MenuDefinition + { + TenantId = tenantId, + CreatedAt = DateTime.UtcNow + }; + + // // 填充字段 + FillEntity(entity, payload); + + // 2. 返回 DTO 映射 + return ToDto(entity); + } + + public static string JoinCodes(IEnumerable codes) + { + return string.Join(',', codes.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct(StringComparer.OrdinalIgnoreCase)); + } + + public static string[] SplitCodes(string? codes) + { + if (string.IsNullOrWhiteSpace(codes)) + { + return Array.Empty(); + } + + return codes + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateMenuCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateMenuCommandHandler.cs new file mode 100644 index 0000000..69a9e13 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateMenuCommandHandler.cs @@ -0,0 +1,52 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Contracts; +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 UpdateMenuCommandHandler( + IMenuRepository menuRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(UpdateMenuCommand request, CancellationToken cancellationToken) + { + // 1. 校验存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var entity = await menuRepository.FindByIdAsync(request.Id, tenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "菜单不存在"); + + // 2. 更新字段 + entity.ParentId = request.ParentId; + entity.Name = request.Name.Trim(); + entity.Path = request.Path.Trim(); + entity.Component = request.Component.Trim(); + entity.Title = request.Title.Trim(); + entity.Icon = request.Icon?.Trim(); + entity.IsIframe = request.IsIframe; + entity.Link = string.IsNullOrWhiteSpace(request.Link) ? null : request.Link.Trim(); + entity.KeepAlive = request.KeepAlive; + entity.SortOrder = request.SortOrder; + entity.RequiredPermissions = MenuMapper.JoinCodes(request.RequiredPermissions); + entity.MetaPermissions = MenuMapper.JoinCodes(request.MetaPermissions); + entity.MetaRoles = MenuMapper.JoinCodes(request.MetaRoles); + entity.AuthListJson = request.AuthList.Count == 0 + ? null + : System.Text.Json.JsonSerializer.Serialize(request.AuthList); + + // 3. 持久化 + await menuRepository.UpdateAsync(entity, cancellationToken); + await menuRepository.SaveChangesAsync(cancellationToken); + + // 4. 返回 DTO + return MenuMapper.ToDto(entity); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/ListMenusQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/ListMenusQuery.cs new file mode 100644 index 0000000..429582b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/ListMenusQuery.cs @@ -0,0 +1,12 @@ +using MediatR; +using System.Collections.Generic; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Queries; + +/// +/// 查询菜单列表(当前租户)。 +/// +public sealed class ListMenusQuery : IRequest> +{ +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/MenuDetailQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/MenuDetailQuery.cs new file mode 100644 index 0000000..8809800 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/MenuDetailQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Queries; + +/// +/// 菜单详情查询。 +/// +public sealed class MenuDetailQuery : IRequest +{ + /// + /// 菜单 ID。 + /// + public long Id { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs index f8ee088..5b00f57 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs @@ -19,6 +19,7 @@ public sealed class AdminAuthService( IRoleRepository roleRepository, IPermissionRepository permissionRepository, IRolePermissionRepository rolePermissionRepository, + IMenuRepository menuRepository, IPasswordHasher passwordHasher, IJwtTokenService jwtTokenService, IRefreshTokenStore refreshTokenStore, @@ -29,6 +30,7 @@ public sealed class AdminAuthService( private readonly IRoleRepository _roleRepository = roleRepository; private readonly IPermissionRepository _permissionRepository = permissionRepository; private readonly IRolePermissionRepository _rolePermissionRepository = rolePermissionRepository; + private readonly IMenuRepository _menuRepository = menuRepository; /// /// 管理后台登录:验证账号密码并生成令牌。 @@ -108,8 +110,12 @@ public sealed class AdminAuthService( { // 1. 读取档案以获取权限 var profile = await GetProfileAsync(userId, cancellationToken); - // 2. 生成菜单树 - var menu = AdminMenuProvider.BuildMenuTree(profile.Permissions); + // 2. 读取菜单定义 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var definitions = await _menuRepository.GetByTenantAsync(tenantId, cancellationToken); + + // 3. 生成菜单树 + var menu = BuildMenuTree(definitions, profile.Permissions); return menu; } @@ -208,6 +214,103 @@ public sealed class AdminAuthService( }; } + private static IReadOnlyList BuildMenuTree( + IReadOnlyList definitions, + IReadOnlyList permissions) + { + // 1. 权限集合 + var permissionSet = new HashSet(permissions ?? [], StringComparer.OrdinalIgnoreCase); + + // 2. 父子分组 + var parentLookup = definitions + .GroupBy(x => x.ParentId) + .ToDictionary(g => g.Key, g => g.OrderBy(d => d.SortOrder).ToList()); + + // 3. 递归构造并过滤 + IReadOnlyList BuildChildren(long parentId) + { + if (!parentLookup.TryGetValue(parentId, out var children)) + { + return []; + } + + // 1. 收集可见节点 + var result = new List(); + foreach (var def in children) + { + // 1.1 构造节点 + var node = MapMenuNode(def); + + // 1.2 递归子节点 + var sub = BuildChildren(def.Id); + + // 1.3 可见性 + var required = node.RequiredPermissions ?? []; + var visible = required.Length == 0 || required.Any(permissionSet.Contains); + + // 1.4 收集 + if (visible || sub.Count > 0) + { + result.Add(node with { Children = sub }); + } + } + + // 2. 返回本层节点 + return result; + } + + // 4. 返回根节点 + return BuildChildren(0); + } + + private static MenuNodeDto MapMenuNode(Domain.Identity.Entities.MenuDefinition definition) + { + // 1. 解析权限 + var requiredPermissions = SplitCodes(definition.RequiredPermissions); + var metaPermissions = SplitCodes(definition.MetaPermissions); + var metaRoles = SplitCodes(definition.MetaRoles); + + // 2. 解析按钮权限 + var authList = string.IsNullOrWhiteSpace(definition.AuthListJson) + ? [] + : System.Text.Json.JsonSerializer.Deserialize>(definition.AuthListJson) ?? []; + + // 3. 构造节点 + return new MenuNodeDto + { + Name = definition.Name, + Path = definition.Path, + Component = definition.Component, + Meta = new MenuMetaDto + { + Title = definition.Title, + Icon = definition.Icon, + KeepAlive = definition.KeepAlive, + IsIframe = definition.IsIframe, + Link = definition.Link, + Roles = metaRoles, + Permissions = metaPermissions, + AuthList = authList + }, + Children = [], + RequiredPermissions = requiredPermissions + }; + } + + private static string[] SplitCodes(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return Array.Empty(); + } + + // 1. 拆分去重 + return value + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + private async Task ResolveUserRolesAsync(long tenantId, long userId, CancellationToken cancellationToken) { var relations = await _userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken); diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/MenuDefinition.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/MenuDefinition.cs new file mode 100644 index 0000000..065b659 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/MenuDefinition.cs @@ -0,0 +1,79 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Identity.Entities; + +/// +/// 管理端菜单定义。 +/// +public sealed class MenuDefinition : MultiTenantEntityBase +{ + /// + /// 父级菜单 ID,根节点为 0。 + /// + public long ParentId { get; set; } + + /// + /// 菜单名称(前端路由 name)。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 路由路径。 + /// + public string Path { get; set; } = string.Empty; + + /// + /// 组件路径(不含 .vue)。 + /// + public string Component { get; set; } = string.Empty; + + /// + /// 标题。 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 图标标识。 + /// + public string? Icon { get; set; } + + /// + /// 是否 iframe。 + /// + public bool IsIframe { get; set; } + + /// + /// 外链或 iframe 地址。 + /// + public string? Link { get; set; } + + /// + /// 是否缓存。 + /// + public bool KeepAlive { get; set; } + + /// + /// 排序。 + /// + public int SortOrder { get; set; } + + /// + /// 访问该菜单所需的权限集合(逗号分隔)。 + /// + public string RequiredPermissions { get; set; } = string.Empty; + + /// + /// Meta.permissions(逗号分隔)。 + /// + public string MetaPermissions { get; set; } = string.Empty; + + /// + /// Meta.roles(逗号分隔)。 + /// + public string MetaRoles { get; set; } = string.Empty; + + /// + /// 按钮权限列表 JSON(存储 MenuAuthItemDto 数组)。 + /// + public string? AuthListJson { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMenuRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMenuRepository.cs new file mode 100644 index 0000000..093c0ce --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMenuRepository.cs @@ -0,0 +1,39 @@ +using TakeoutSaaS.Domain.Identity.Entities; + +namespace TakeoutSaaS.Domain.Identity.Repositories; + +/// +/// 菜单仓储。 +/// +public interface IMenuRepository +{ + /// + /// 按租户获取菜单列表。 + /// + Task> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 根据 ID 查询菜单。 + /// + Task FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增菜单。 + /// + Task AddAsync(MenuDefinition menu, CancellationToken cancellationToken = default); + + /// + /// 更新菜单。 + /// + Task UpdateAsync(MenuDefinition menu, CancellationToken cancellationToken = default); + + /// + /// 删除菜单。 + /// + Task DeleteAsync(long id, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 持久化变更。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs index ef2fd47..231f604 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using TakeoutSaaS.Domain.Identity.Repositories; using TakeoutSaaS.Infrastructure.Common.Extensions; using TakeoutSaaS.Infrastructure.Identity.Options; using TakeoutSaaS.Infrastructure.Identity.Persistence; +using TakeoutSaaS.Infrastructure.Identity.Repositories; using TakeoutSaaS.Infrastructure.Identity.Services; using TakeoutSaaS.Shared.Abstractions.Constants; using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser; @@ -53,6 +54,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs index 268a7c0..a553f2f 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs @@ -58,6 +58,11 @@ public sealed class IdentityDbContext( /// public DbSet RolePermissions => Set(); + /// + /// 菜单定义集合。 + /// + public DbSet MenuDefinitions => Set(); + /// /// 配置实体模型。 /// @@ -73,6 +78,7 @@ public sealed class IdentityDbContext( ConfigurePermission(modelBuilder.Entity()); ConfigureUserRole(modelBuilder.Entity()); ConfigureRolePermission(modelBuilder.Entity()); + ConfigureMenuDefinition(modelBuilder.Entity()); ApplyTenantQueryFilters(modelBuilder); } @@ -191,4 +197,26 @@ public sealed class IdentityDbContext( builder.HasIndex(x => x.TenantId); builder.HasIndex(x => new { x.TenantId, x.RoleId, x.PermissionId }).IsUnique(); } + + private static void ConfigureMenuDefinition(EntityTypeBuilder builder) + { + builder.ToTable("menu_definitions"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.ParentId).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Path).HasMaxLength(256).IsRequired(); + builder.Property(x => x.Component).HasMaxLength(256).IsRequired(); + builder.Property(x => x.Title).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Icon).HasMaxLength(64); + builder.Property(x => x.Link).HasMaxLength(512); + builder.Property(x => x.SortOrder).IsRequired(); + builder.Property(x => x.RequiredPermissions).HasMaxLength(1024); + builder.Property(x => x.MetaPermissions).HasMaxLength(1024); + builder.Property(x => x.MetaRoles).HasMaxLength(1024); + builder.Property(x => x.AuthListJson).HasColumnType("text"); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); + builder.HasIndex(x => new { x.TenantId, x.ParentId, x.SortOrder }); + } } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Repositories/EfMenuRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Repositories/EfMenuRepository.cs new file mode 100644 index 0000000..4046b4f --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Repositories/EfMenuRepository.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Infrastructure.Identity.Persistence; + +namespace TakeoutSaaS.Infrastructure.Identity.Repositories; + +/// +/// 菜单仓储 EF 实现。 +/// +public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuRepository +{ + /// + public Task> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default) + { + return dbContext.MenuDefinitions + .AsNoTracking() + .Where(x => x.TenantId == tenantId) + .OrderBy(x => x.ParentId) + .ThenBy(x => x.SortOrder) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + /// + public Task FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default) + { + return dbContext.MenuDefinitions + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == id && x.TenantId == tenantId, cancellationToken); + } + + /// + public Task AddAsync(MenuDefinition menu, CancellationToken cancellationToken = default) + { + return dbContext.MenuDefinitions.AddAsync(menu, cancellationToken).AsTask(); + } + + /// + public Task UpdateAsync(MenuDefinition menu, CancellationToken cancellationToken = default) + { + dbContext.MenuDefinitions.Update(menu); + return Task.CompletedTask; + } + + /// + public async Task DeleteAsync(long id, long tenantId, CancellationToken cancellationToken = default) + { + // 1. 查询目标 + var entity = await dbContext.MenuDefinitions + .FirstOrDefaultAsync(x => x.Id == id && x.TenantId == tenantId, cancellationToken); + + // 2. 存在则删除 + if (entity is not null) + { + dbContext.MenuDefinitions.Remove(entity); + } + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251205131436_AddMenuDefinitions.Designer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251205131436_AddMenuDefinitions.Designer.cs new file mode 100644 index 0000000..52a63a8 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251205131436_AddMenuDefinitions.Designer.cs @@ -0,0 +1,667 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TakeoutSaaS.Infrastructure.Identity.Persistence; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20251205131436_AddMenuDefinitions")] + partial class AddMenuDefinitions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.IdentityUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Account") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("登录账号。"); + + b.Property("Avatar") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("头像地址。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("展示名称。"); + + b.Property("MerchantId") + .HasColumnType("bigint") + .HasComment("所属商户(平台管理员为空)。"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("密码哈希。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Account") + .IsUnique(); + + b.ToTable("identity_users", null, t => + { + t.HasComment("管理后台账户实体(平台管理员、租户管理员或商户员工)。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.MenuDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthListJson") + .HasColumnType("text") + .HasComment("按钮权限列表 JSON(存储 MenuAuthItemDto 数组)。"); + + b.Property("Component") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("组件路径(不含 .vue)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("图标标识。"); + + b.Property("IsIframe") + .HasColumnType("boolean") + .HasComment("是否 iframe。"); + + b.Property("KeepAlive") + .HasColumnType("boolean") + .HasComment("是否缓存。"); + + b.Property("Link") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("外链或 iframe 地址。"); + + b.Property("MetaPermissions") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("Meta.permissions(逗号分隔)。"); + + b.Property("MetaRoles") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("Meta.roles(逗号分隔)。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("菜单名称(前端路由 name)。"); + + b.Property("ParentId") + .HasColumnType("bigint") + .HasComment("父级菜单 ID,根节点为 0。"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("路由路径。"); + + b.Property("RequiredPermissions") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("访问该菜单所需的权限集合(逗号分隔)。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("标题。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ParentId", "SortOrder"); + + b.ToTable("menu_definitions", null, t => + { + t.HasComment("管理端菜单定义。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.MiniUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Avatar") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("头像地址。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("昵称。"); + + b.Property("OpenId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("微信 OpenId。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UnionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("微信 UnionId,可能为空。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "OpenId") + .IsUnique(); + + b.ToTable("mini_users", null, t => + { + t.HasComment("小程序用户实体。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("权限编码(租户内唯一)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("描述。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("权限名称。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("permissions", null, t => + { + t.HasComment("权限定义。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("角色编码(租户内唯一)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("描述。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("角色名称。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("roles", null, t => + { + t.HasComment("角色定义。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("PermissionId") + .HasColumnType("bigint") + .HasComment("权限 ID。"); + + b.Property("RoleId") + .HasColumnType("bigint") + .HasComment("角色 ID。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "PermissionId") + .IsUnique(); + + b.ToTable("role_permissions", null, t => + { + t.HasComment("角色-权限关系。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.RoleTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("模板描述。"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasComment("是否启用。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("模板名称。"); + + b.Property("TemplateCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("模板编码(唯一)。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TemplateCode") + .IsUnique(); + + b.ToTable("role_templates", null, t => + { + t.HasComment("角色模板定义(平台级)。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.RoleTemplatePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("PermissionCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("权限编码。"); + + b.Property("RoleTemplateId") + .HasColumnType("bigint") + .HasComment("模板 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("RoleTemplateId", "PermissionCode") + .IsUnique(); + + b.ToTable("role_template_permissions", null, t => + { + t.HasComment("角色模板-权限关系(平台级)。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.UserRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("RoleId") + .HasColumnType("bigint") + .HasComment("角色 ID。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户 ID。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("user_roles", null, t => + { + t.HasComment("用户-角色关系。"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251205131436_AddMenuDefinitions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251205131436_AddMenuDefinitions.cs new file mode 100644 index 0000000..7713d9b --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251205131436_AddMenuDefinitions.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb +{ + /// + public partial class AddMenuDefinitions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "menu_definitions", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ParentId = table.Column(type: "bigint", nullable: false, comment: "父级菜单 ID,根节点为 0。"), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "菜单名称(前端路由 name)。"), + Path = table.Column(type: "character varying(256)", maxLength: 256, nullable: false, comment: "路由路径。"), + Component = table.Column(type: "character varying(256)", maxLength: 256, nullable: false, comment: "组件路径(不含 .vue)。"), + Title = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "标题。"), + Icon = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "图标标识。"), + IsIframe = table.Column(type: "boolean", nullable: false, comment: "是否 iframe。"), + Link = table.Column(type: "character varying(512)", maxLength: 512, nullable: true, comment: "外链或 iframe 地址。"), + KeepAlive = table.Column(type: "boolean", nullable: false, comment: "是否缓存。"), + SortOrder = table.Column(type: "integer", nullable: false, comment: "排序。"), + RequiredPermissions = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false, comment: "访问该菜单所需的权限集合(逗号分隔)。"), + MetaPermissions = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false, comment: "Meta.permissions(逗号分隔)。"), + MetaRoles = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false, comment: "Meta.roles(逗号分隔)。"), + AuthListJson = table.Column(type: "text", nullable: true, comment: "按钮权限列表 JSON(存储 MenuAuthItemDto 数组)。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_menu_definitions", x => x.Id); + }, + comment: "管理端菜单定义。"); + + migrationBuilder.CreateIndex( + name: "IX_menu_definitions_TenantId_ParentId_SortOrder", + table: "menu_definitions", + columns: new[] { "TenantId", "ParentId", "SortOrder" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "menu_definitions"); + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/IdentityDbContextModelSnapshot.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/IdentityDbContextModelSnapshot.cs index 01f7f4a..2a8b361 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/IdentityDbContextModelSnapshot.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/IdentityDbContextModelSnapshot.cs @@ -99,6 +99,125 @@ namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb }); }); + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.MenuDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthListJson") + .HasColumnType("text") + .HasComment("按钮权限列表 JSON(存储 MenuAuthItemDto 数组)。"); + + b.Property("Component") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("组件路径(不含 .vue)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("图标标识。"); + + b.Property("IsIframe") + .HasColumnType("boolean") + .HasComment("是否 iframe。"); + + b.Property("KeepAlive") + .HasColumnType("boolean") + .HasComment("是否缓存。"); + + b.Property("Link") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("外链或 iframe 地址。"); + + b.Property("MetaPermissions") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("Meta.permissions(逗号分隔)。"); + + b.Property("MetaRoles") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("Meta.roles(逗号分隔)。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("菜单名称(前端路由 name)。"); + + b.Property("ParentId") + .HasColumnType("bigint") + .HasComment("父级菜单 ID,根节点为 0。"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("路由路径。"); + + b.Property("RequiredPermissions") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("访问该菜单所需的权限集合(逗号分隔)。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("标题。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ParentId", "SortOrder"); + + b.ToTable("menu_definitions", null, t => + { + t.HasComment("管理端菜单定义。"); + }); + }); + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.MiniUser", b => { b.Property("Id")