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")