From 3a38ade302e44caab8e9154d815750603b1e8bda Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Fri, 5 Dec 2025 20:55:56 +0800
Subject: [PATCH] feat: add role template and tenant role apis
---
.../Controllers/RoleTemplatesController.cs | 217 +
.../Controllers/TenantRolesController.cs | 209 +
.../Commands/CloneRoleTemplateCommand.cs | 40 +
.../Identity/Contracts/RoleDetailDto.cs | 37 +
.../CloneRoleTemplateCommandHandler.cs | 73 +
.../Handlers/RoleDetailQueryHandler.cs | 60 +
.../RoleTemplatePermissionsQueryHandler.cs | 40 +
.../Identity/Queries/RoleDetailQuery.cs | 15 +
.../Queries/RoleTemplatePermissionsQuery.cs | 15 +
...8_AddTenantVerificationProfile.Designer.cs | 6673 +++++++++++++++++
...1205113018_AddTenantVerificationProfile.cs | 544 ++
.../TakeoutAppDbContextModelSnapshot.cs | 878 ++-
12 files changed, 8799 insertions(+), 2 deletions(-)
create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/RoleTemplatesController.cs
create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/TenantRolesController.cs
create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Commands/CloneRoleTemplateCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleDetailDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/CloneRoleTemplateCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/RoleDetailQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/RoleTemplatePermissionsQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Queries/RoleDetailQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Queries/RoleTemplatePermissionsQuery.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251205113018_AddTenantVerificationProfile.Designer.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251205113018_AddTenantVerificationProfile.cs
diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/RoleTemplatesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/RoleTemplatesController.cs
new file mode 100644
index 0000000..f5853ac
--- /dev/null
+++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/RoleTemplatesController.cs
@@ -0,0 +1,217 @@
+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}/role-templates")]
+public sealed class RoleTemplatesController(IMediator mediator) : BaseApiController
+{
+ ///
+ /// 分页查询角色模板。
+ ///
+ /// 是否启用筛选。
+ /// 取消标记。
+ /// 角色模板列表。
+ [HttpGet]
+ [PermissionAuthorize("identity:role:read")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> List([FromQuery] bool? isActive, CancellationToken cancellationToken)
+ {
+ // 1. 构造查询参数
+ var query = new ListRoleTemplatesQuery { IsActive = isActive };
+
+ // 2. 查询模板集合
+ var result = await mediator.Send(query, cancellationToken);
+
+ // 3. 返回模板列表
+ return ApiResponse>.Ok(result);
+ }
+
+ ///
+ /// 克隆角色模板。
+ ///
+ /// 源模板编码。
+ /// 克隆命令。
+ /// 取消标记。
+ /// 新模板详情。
+ [HttpPost("{templateCode}/clone")]
+ [PermissionAuthorize("role-template:create")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Clone(
+ string templateCode,
+ [FromBody, Required] CloneRoleTemplateCommand command,
+ CancellationToken cancellationToken)
+ {
+ // 1. 绑定源模板编码
+ command = command with { SourceTemplateCode = templateCode };
+
+ // 2. 执行克隆
+ var result = await mediator.Send(command, cancellationToken);
+
+ // 3. 返回新模板
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 获取角色模板详情。
+ ///
+ /// 模板编码。
+ /// 取消标记。
+ /// 角色模板详情。
+ [HttpGet("{templateCode}")]
+ [PermissionAuthorize("identity:role:read")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
+ public async Task> Detail(string templateCode, CancellationToken cancellationToken)
+ {
+ // 1. 查询模板详情
+ var result = await mediator.Send(new GetRoleTemplateQuery { TemplateCode = templateCode }, cancellationToken);
+
+ // 2. 返回模板或 404
+ return result is null
+ ? ApiResponse.Error(StatusCodes.Status404NotFound, "角色模板不存在")
+ : ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 创建角色模板。
+ ///
+ /// 创建命令。
+ /// 取消标记。
+ /// 创建后的模板。
+ [HttpPost]
+ [PermissionAuthorize("role-template:create")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Create([FromBody, Required] CreateRoleTemplateCommand command, CancellationToken cancellationToken)
+ {
+ // 1. 创建模板
+ var result = await mediator.Send(command, cancellationToken);
+
+ // 2. 返回创建结果
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 获取模板的权限列表。
+ ///
+ /// 模板编码。
+ /// 取消标记。
+ /// 权限集合。
+ [HttpGet("{templateCode}/permissions")]
+ [PermissionAuthorize("identity:role:read")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> GetPermissions(string templateCode, CancellationToken cancellationToken)
+ {
+ // 1. 查询模板权限
+ var result = await mediator.Send(new RoleTemplatePermissionsQuery { TemplateCode = templateCode }, cancellationToken);
+
+ // 2. 返回权限集合
+ return ApiResponse>.Ok(result);
+ }
+
+ ///
+ /// 更新角色模板。
+ ///
+ /// 模板编码。
+ /// 更新命令。
+ /// 取消标记。
+ /// 更新后的模板。
+ [HttpPut("{templateCode}")]
+ [PermissionAuthorize("role-template:update")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
+ public async Task> Update(
+ string templateCode,
+ [FromBody, Required] UpdateRoleTemplateCommand command,
+ CancellationToken cancellationToken)
+ {
+ // 1. 绑定模板编码
+ command = command with { TemplateCode = templateCode };
+
+ // 2. 执行更新
+ var result = await mediator.Send(command, cancellationToken);
+
+ // 3. 返回更新结果或 404
+ return result is null
+ ? ApiResponse.Error(StatusCodes.Status404NotFound, "角色模板不存在")
+ : ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 删除角色模板。
+ ///
+ /// 模板编码。
+ /// 取消标记。
+ /// 删除结果。
+ [HttpDelete("{templateCode}")]
+ [PermissionAuthorize("role-template:delete")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Delete(string templateCode, CancellationToken cancellationToken)
+ {
+ // 1. 执行删除
+ var result = await mediator.Send(new DeleteRoleTemplateCommand { TemplateCode = templateCode }, cancellationToken);
+
+ // 2. 返回执行结果
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 为当前租户批量初始化预置角色模板。
+ ///
+ /// 初始化命令。
+ /// 取消标记。
+ /// 生成的租户角色列表。
+ [HttpPost("init")]
+ [PermissionAuthorize("identity:role:create")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> Initialize(
+ [FromBody] InitializeRoleTemplatesCommand? command,
+ CancellationToken cancellationToken)
+ {
+ // 1. 确保命令存在
+ command ??= new InitializeRoleTemplatesCommand();
+
+ // 2. 初始化模板到租户
+ var result = await mediator.Send(command, cancellationToken);
+
+ // 3. 返回新建的角色列表
+ return ApiResponse>.Ok(result);
+ }
+
+ ///
+ /// 将单个模板初始化到当前租户。
+ ///
+ /// 模板编码。
+ /// 取消标记。
+ /// 生成的角色列表。
+ [HttpPost("{templateCode}/initialize-tenant")]
+ [PermissionAuthorize("identity:role:create")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> InitializeSingle(string templateCode, CancellationToken cancellationToken)
+ {
+ // 1. 构造初始化命令
+ var command = new InitializeRoleTemplatesCommand
+ {
+ TemplateCodes = new[] { templateCode }
+ };
+
+ // 2. 初始化模板到租户
+ var result = await mediator.Send(command, cancellationToken);
+
+ // 3. 返回生成的角色列表
+ return ApiResponse>.Ok(result);
+ }
+}
diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantRolesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantRolesController.cs
new file mode 100644
index 0000000..a1bb668
--- /dev/null
+++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantRolesController.cs
@@ -0,0 +1,209 @@
+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.Abstractions.Tenancy;
+using TakeoutSaaS.Shared.Web.Api;
+
+namespace TakeoutSaaS.AdminApi.Controllers;
+
+///
+/// 租户角色管理(实例层)。
+///
+[ApiVersion("1.0")]
+[Authorize]
+[Route("api/admin/v{version:apiVersion}/tenants/{tenantId:long}/roles")]
+public sealed class TenantRolesController(IMediator mediator, ITenantProvider tenantProvider) : BaseApiController
+{
+ ///
+ /// 租户角色分页。
+ ///
+ [HttpGet]
+ [PermissionAuthorize("identity:role:read")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> List(
+ long tenantId,
+ [FromQuery] SearchRolesQuery query,
+ CancellationToken cancellationToken)
+ {
+ // 1. 校验路由租户与上下文一致
+ var currentTenantId = tenantProvider.GetCurrentTenantId();
+ if (tenantId != currentTenantId)
+ {
+ return ApiResponse>.Error(StatusCodes.Status400BadRequest, "租户上下文不一致");
+ }
+
+ // 2. 查询角色分页
+ var result = await mediator.Send(query, cancellationToken);
+
+ // 3. 返回分页数据
+ return ApiResponse>.Ok(result);
+ }
+
+ ///
+ /// 角色详情(含权限)。
+ ///
+ [HttpGet("{roleId:long}")]
+ [PermissionAuthorize("identity:role:read")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
+ public async Task> Detail(long tenantId, long roleId, CancellationToken cancellationToken)
+ {
+ // 1. 校验租户上下文
+ var currentTenantId = tenantProvider.GetCurrentTenantId();
+ if (tenantId != currentTenantId)
+ {
+ return ApiResponse.Error(StatusCodes.Status400BadRequest, "租户上下文不一致");
+ }
+
+ // 2. 查询角色详情
+ var result = await mediator.Send(new RoleDetailQuery { RoleId = roleId }, cancellationToken);
+
+ // 3. 返回数据或 404
+ return result is null
+ ? ApiResponse.Error(StatusCodes.Status404NotFound, "角色不存在")
+ : ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 创建角色。
+ ///
+ [HttpPost]
+ [PermissionAuthorize("identity:role:create")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Create(
+ long tenantId,
+ [FromBody, Required] CreateRoleCommand command,
+ CancellationToken cancellationToken)
+ {
+ // 1. 校验租户上下文
+ var currentTenantId = tenantProvider.GetCurrentTenantId();
+ if (tenantId != currentTenantId)
+ {
+ return ApiResponse.Error(StatusCodes.Status400BadRequest, "租户上下文不一致");
+ }
+
+ // 2. 创建角色
+ var result = await mediator.Send(command, cancellationToken);
+
+ // 3. 返回创建结果
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 更新角色。
+ ///
+ [HttpPut("{roleId:long}")]
+ [PermissionAuthorize("identity:role:update")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
+ public async Task> Update(
+ long tenantId,
+ long roleId,
+ [FromBody, Required] UpdateRoleCommand command,
+ CancellationToken cancellationToken)
+ {
+ // 1. 校验租户上下文
+ var currentTenantId = tenantProvider.GetCurrentTenantId();
+ if (tenantId != currentTenantId)
+ {
+ return ApiResponse.Error(StatusCodes.Status400BadRequest, "租户上下文不一致");
+ }
+
+ // 2. 绑定角色 ID
+ command = command with { RoleId = roleId };
+
+ // 3. 执行更新
+ var result = await mediator.Send(command, cancellationToken);
+
+ // 4. 返回结果或 404
+ return result is null
+ ? ApiResponse.Error(StatusCodes.Status404NotFound, "角色不存在")
+ : ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 删除角色。
+ ///
+ [HttpDelete("{roleId:long}")]
+ [PermissionAuthorize("identity:role:delete")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Delete(long tenantId, long roleId, CancellationToken cancellationToken)
+ {
+ // 1. 校验租户上下文
+ var currentTenantId = tenantProvider.GetCurrentTenantId();
+ if (tenantId != currentTenantId)
+ {
+ return ApiResponse.Error(StatusCodes.Status400BadRequest, "租户上下文不一致");
+ }
+
+ // 2. 执行删除
+ var command = new DeleteRoleCommand { RoleId = roleId };
+ var result = await mediator.Send(command, cancellationToken);
+
+ // 3. 返回结果
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 获取角色权限列表。
+ ///
+ [HttpGet("{roleId:long}/permissions")]
+ [PermissionAuthorize("identity:role:read")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status404NotFound)]
+ public async Task>> GetPermissions(
+ long tenantId,
+ long roleId,
+ CancellationToken cancellationToken)
+ {
+ // 1. 校验租户上下文
+ var currentTenantId = tenantProvider.GetCurrentTenantId();
+ if (tenantId != currentTenantId)
+ {
+ return ApiResponse>.Error(StatusCodes.Status400BadRequest, "租户上下文不一致");
+ }
+
+ // 2. 查询角色详情并提取权限
+ var detail = await mediator.Send(new RoleDetailQuery { RoleId = roleId }, cancellationToken);
+ if (detail is null)
+ {
+ return ApiResponse>.Error(StatusCodes.Status404NotFound, "角色不存在");
+ }
+
+ // 3. 返回权限集合
+ return ApiResponse>.Ok(detail.Permissions);
+ }
+
+ ///
+ /// 覆盖角色权限。
+ ///
+ [HttpPut("{roleId:long}/permissions")]
+ [PermissionAuthorize("identity:role:bind-permission")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> BindPermissions(
+ long tenantId,
+ long roleId,
+ [FromBody, Required] BindRolePermissionsCommand command,
+ CancellationToken cancellationToken)
+ {
+ // 1. 校验租户上下文
+ var currentTenantId = tenantProvider.GetCurrentTenantId();
+ if (tenantId != currentTenantId)
+ {
+ return ApiResponse.Error(StatusCodes.Status400BadRequest, "租户上下文不一致");
+ }
+
+ // 2. 绑定角色 ID
+ command = command with { RoleId = roleId };
+
+ // 3. 覆盖授权
+ var result = await mediator.Send(command, cancellationToken);
+ return ApiResponse.Ok(result);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/CloneRoleTemplateCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/CloneRoleTemplateCommand.cs
new file mode 100644
index 0000000..bf60e8a
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/CloneRoleTemplateCommand.cs
@@ -0,0 +1,40 @@
+using MediatR;
+using TakeoutSaaS.Application.Identity.Contracts;
+
+namespace TakeoutSaaS.Application.Identity.Commands;
+
+///
+/// 克隆角色模板。
+///
+public sealed record CloneRoleTemplateCommand : IRequest
+{
+ ///
+ /// 源模板编码。
+ ///
+ public string SourceTemplateCode { get; init; } = string.Empty;
+
+ ///
+ /// 新模板编码。
+ ///
+ public string NewTemplateCode { get; init; } = string.Empty;
+
+ ///
+ /// 新模板名称(为空则沿用源模板)。
+ ///
+ public string? Name { get; init; }
+
+ ///
+ /// 新模板描述(为空则沿用源模板)。
+ ///
+ public string? Description { get; init; }
+
+ ///
+ /// 是否启用(为空则沿用源模板)。
+ ///
+ public bool? IsActive { get; init; }
+
+ ///
+ /// 权限编码集合(为空则沿用源模板权限)。
+ ///
+ public IReadOnlyCollection? PermissionCodes { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleDetailDto.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleDetailDto.cs
new file mode 100644
index 0000000..e6983c1
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleDetailDto.cs
@@ -0,0 +1,37 @@
+namespace TakeoutSaaS.Application.Identity.Contracts;
+
+///
+/// 角色详情 DTO。
+///
+public sealed record RoleDetailDto
+{
+ ///
+ /// 角色 ID。
+ ///
+ public long Id { get; init; }
+
+ ///
+ /// 租户 ID。
+ ///
+ public long TenantId { get; init; }
+
+ ///
+ /// 角色名称。
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// 角色编码。
+ ///
+ public string Code { get; init; } = string.Empty;
+
+ ///
+ /// 描述。
+ ///
+ public string? Description { get; init; }
+
+ ///
+ /// 权限列表。
+ ///
+ public IReadOnlyList Permissions { get; init; } = [];
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CloneRoleTemplateCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CloneRoleTemplateCommandHandler.cs
new file mode 100644
index 0000000..b4bde94
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CloneRoleTemplateCommandHandler.cs
@@ -0,0 +1,73 @@
+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.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+
+namespace TakeoutSaaS.Application.Identity.Handlers;
+
+///
+/// 角色模板克隆处理器。
+///
+public sealed class CloneRoleTemplateCommandHandler(IRoleTemplateRepository roleTemplateRepository)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(CloneRoleTemplateCommand request, CancellationToken cancellationToken)
+ {
+ // 1. 校验源模板是否存在
+ var source = await roleTemplateRepository.FindByCodeAsync(request.SourceTemplateCode, cancellationToken)
+ ?? throw new BusinessException(ErrorCodes.NotFound, $"角色模板 {request.SourceTemplateCode} 不存在");
+
+ // 2. 校验新模板编码是否冲突
+ var exists = await roleTemplateRepository.FindByCodeAsync(request.NewTemplateCode, cancellationToken);
+ if (exists is not null)
+ {
+ throw new BusinessException(ErrorCodes.Conflict, $"角色模板编码 {request.NewTemplateCode} 已存在");
+ }
+
+ // 3. 获取源模板权限
+ var sourcePermissions = await roleTemplateRepository.GetPermissionsAsync(source.Id, cancellationToken);
+ var permissionCodes = request.PermissionCodes is not null && request.PermissionCodes.Count > 0
+ ? request.PermissionCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToArray()
+ : sourcePermissions
+ .Select(x => x.PermissionCode)
+ .Where(code => !string.IsNullOrWhiteSpace(code))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ // 4. 构造新模板实体
+ var target = new RoleTemplate
+ {
+ TemplateCode = request.NewTemplateCode.Trim(),
+ Name = string.IsNullOrWhiteSpace(request.Name) ? source.Name : request.Name.Trim(),
+ Description = request.Description ?? source.Description,
+ IsActive = request.IsActive ?? source.IsActive
+ };
+
+ // 5. 持久化新模板与权限
+ await roleTemplateRepository.AddAsync(target, permissionCodes, cancellationToken);
+ await roleTemplateRepository.SaveChangesAsync(cancellationToken);
+
+ // 6. 映射返回 DTO
+ var permissionDtos = permissionCodes
+ .Select(code => new PermissionTemplateDto
+ {
+ Code = code,
+ Name = code,
+ Description = code
+ })
+ .ToList();
+
+ return new RoleTemplateDto
+ {
+ TemplateCode = target.TemplateCode,
+ Name = target.Name,
+ Description = target.Description,
+ IsActive = target.IsActive,
+ Permissions = permissionDtos
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/RoleDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/RoleDetailQueryHandler.cs
new file mode 100644
index 0000000..3dc6b5b
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/RoleDetailQueryHandler.cs
@@ -0,0 +1,60 @@
+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 RoleDetailQueryHandler(
+ IRoleRepository roleRepository,
+ IRolePermissionRepository rolePermissionRepository,
+ IPermissionRepository permissionRepository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(RoleDetailQuery request, CancellationToken cancellationToken)
+ {
+ // 1. 获取租户上下文并查询角色
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var role = await roleRepository.FindByIdAsync(request.RoleId, tenantId, cancellationToken);
+ if (role is null)
+ {
+ return null;
+ }
+
+ // 2. 查询角色权限关系
+ var relations = await rolePermissionRepository.GetByRoleIdsAsync(tenantId, new[] { role.Id }, cancellationToken);
+ var permissionIds = relations.Select(x => x.PermissionId).ToArray();
+
+ // 3. 拉取权限实体
+ var permissions = permissionIds.Length == 0
+ ? Array.Empty()
+ : await permissionRepository.GetByIdsAsync(tenantId, permissionIds, cancellationToken);
+
+ // 4. 映射 DTO
+ var permissionDtos = permissions
+ .Select(x => new PermissionDto
+ {
+ Id = x.Id,
+ Code = x.Code,
+ Name = x.Name,
+ Description = x.Description
+ })
+ .ToList();
+
+ return new RoleDetailDto
+ {
+ Id = role.Id,
+ TenantId = role.TenantId,
+ Name = role.Name,
+ Code = role.Code,
+ Description = role.Description,
+ Permissions = permissionDtos
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/RoleTemplatePermissionsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/RoleTemplatePermissionsQueryHandler.cs
new file mode 100644
index 0000000..0c5df21
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/RoleTemplatePermissionsQueryHandler.cs
@@ -0,0 +1,40 @@
+using MediatR;
+using TakeoutSaaS.Application.Identity.Contracts;
+using TakeoutSaaS.Application.Identity.Queries;
+using TakeoutSaaS.Domain.Identity.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+
+namespace TakeoutSaaS.Application.Identity.Handlers;
+
+///
+/// 角色模板权限查询处理器。
+///
+public sealed class RoleTemplatePermissionsQueryHandler(IRoleTemplateRepository roleTemplateRepository)
+ : IRequestHandler>
+{
+ ///
+ public async Task> Handle(RoleTemplatePermissionsQuery request, CancellationToken cancellationToken)
+ {
+ // 1. 校验模板存在
+ var template = await roleTemplateRepository.FindByCodeAsync(request.TemplateCode, cancellationToken)
+ ?? throw new BusinessException(ErrorCodes.NotFound, $"角色模板 {request.TemplateCode} 不存在");
+
+ // 2. 查询模板权限
+ var permissions = await roleTemplateRepository.GetPermissionsAsync(template.Id, cancellationToken);
+
+ // 3. 映射 DTO
+ var dto = permissions
+ .Where(x => !string.IsNullOrWhiteSpace(x.PermissionCode))
+ .Select(x => new PermissionTemplateDto
+ {
+ Code = x.PermissionCode,
+ Name = x.PermissionCode,
+ Description = x.PermissionCode
+ })
+ .ToList();
+
+ // 4. 返回权限列表
+ return dto;
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/RoleDetailQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/RoleDetailQuery.cs
new file mode 100644
index 0000000..c5ee067
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/RoleDetailQuery.cs
@@ -0,0 +1,15 @@
+using MediatR;
+using TakeoutSaaS.Application.Identity.Contracts;
+
+namespace TakeoutSaaS.Application.Identity.Queries;
+
+///
+/// 查询角色详情(含权限)。
+///
+public sealed class RoleDetailQuery : IRequest
+{
+ ///
+ /// 角色 ID。
+ ///
+ public long RoleId { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/RoleTemplatePermissionsQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/RoleTemplatePermissionsQuery.cs
new file mode 100644
index 0000000..ad84c9d
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/RoleTemplatePermissionsQuery.cs
@@ -0,0 +1,15 @@
+using MediatR;
+using TakeoutSaaS.Application.Identity.Contracts;
+
+namespace TakeoutSaaS.Application.Identity.Queries;
+
+///
+/// 查询角色模板权限列表。
+///
+public sealed class RoleTemplatePermissionsQuery : IRequest>
+{
+ ///
+ /// 模板编码。
+ ///
+ public string TemplateCode { get; init; } = string.Empty;
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251205113018_AddTenantVerificationProfile.Designer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251205113018_AddTenantVerificationProfile.Designer.cs
new file mode 100644
index 0000000..4e3fa03
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251205113018_AddTenantVerificationProfile.Designer.cs
@@ -0,0 +1,6673 @@
+//
+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.App.Persistence;
+
+#nullable disable
+
+namespace TakeoutSaaS.Infrastructure.Migrations
+{
+ [DbContext(typeof(TakeoutAppDbContext))]
+ [Migration("20251205113018_AddTenantVerificationProfile")]
+ partial class AddTenantVerificationProfile
+ {
+ ///
+ 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.Analytics.Entities.MetricAlertRule", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConditionJson")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasComment("触发条件 JSON。");
+
+ 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("Enabled")
+ .HasColumnType("boolean")
+ .HasComment("是否启用。");
+
+ b.Property("MetricDefinitionId")
+ .HasColumnType("bigint")
+ .HasComment("关联指标。");
+
+ b.Property("NotificationChannels")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasComment("通知渠道。");
+
+ b.Property("Severity")
+ .HasColumnType("integer")
+ .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", "MetricDefinitionId", "Severity");
+
+ b.ToTable("metric_alert_rules", null, t =>
+ {
+ t.HasComment("指标告警规则。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricDefinition", 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("DefaultAggregation")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasComment("默认聚合方式。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("Description")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasComment("说明。");
+
+ b.Property("DimensionsJson")
+ .HasColumnType("text")
+ .HasComment("维度描述 JSON。");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .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", "Code")
+ .IsUnique();
+
+ b.ToTable("metric_definitions", null, t =>
+ {
+ t.HasComment("指标定义,描述可观测的数据点。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricSnapshot", 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("DimensionKey")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasComment("维度键(JSON)。");
+
+ b.Property("MetricDefinitionId")
+ .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("Value")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)")
+ .HasComment("数值。");
+
+ b.Property("WindowEnd")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("统计时间窗口结束。");
+
+ b.Property("WindowStart")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("统计时间窗口开始。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "MetricDefinitionId", "DimensionKey", "WindowStart", "WindowEnd")
+ .IsUnique();
+
+ b.ToTable("metric_snapshots", null, t =>
+ {
+ t.HasComment("指标快照,用于大盘展示。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.Coupon", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasComment("券码或序列号。");
+
+ b.Property("CouponTemplateId")
+ .HasColumnType("bigint")
+ .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("ExpireAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("到期时间。");
+
+ b.Property("IssuedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("发放时间。");
+
+ b.Property("OrderId")
+ .HasColumnType("bigint")
+ .HasComment("订单 ID(已使用时记录)。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .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.Property("UsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("使用时间。");
+
+ b.Property("UserId")
+ .HasColumnType("bigint")
+ .HasComment("归属用户。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "Code")
+ .IsUnique();
+
+ b.ToTable("coupons", null, t =>
+ {
+ t.HasComment("用户领取的券。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.CouponTemplate", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AllowStack")
+ .HasColumnType("boolean")
+ .HasComment("是否允许叠加其他优惠。");
+
+ b.Property("ChannelsJson")
+ .HasColumnType("text")
+ .HasComment("发放渠道(JSON)。");
+
+ b.Property("ClaimedQuantity")
+ .HasColumnType("integer")
+ .HasComment("已领取数量。");
+
+ b.Property("CouponType")
+ .HasColumnType("integer")
+ .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(512)
+ .HasColumnType("character varying(512)")
+ .HasComment("备注。");
+
+ b.Property("DiscountCap")
+ .HasColumnType("numeric")
+ .HasComment("折扣上限(针对折扣券)。");
+
+ b.Property("MinimumSpend")
+ .HasColumnType("numeric")
+ .HasComment("最低消费门槛。");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasComment("模板名称。");
+
+ b.Property("ProductScopeJson")
+ .HasColumnType("text")
+ .HasComment("适用品类或商品范围(JSON)。");
+
+ b.Property("RelativeValidDays")
+ .HasColumnType("integer")
+ .HasComment("有效天数(相对发放时间)。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasComment("状态。");
+
+ b.Property("StoreScopeJson")
+ .HasColumnType("text")
+ .HasComment("适用门店 ID 集合(JSON)。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("TotalQuantity")
+ .HasColumnType("integer")
+ .HasComment("总发放数量上限。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("ValidFrom")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("可用开始时间。");
+
+ b.Property("ValidTo")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("可用结束时间。");
+
+ b.Property("Value")
+ .HasColumnType("numeric")
+ .HasComment("面值或折扣额度。");
+
+ b.HasKey("Id");
+
+ b.ToTable("coupon_templates", null, t =>
+ {
+ t.HasComment("优惠券模板。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.PromotionCampaign", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AudienceDescription")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasComment("目标人群描述。");
+
+ b.Property("BannerUrl")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasComment("营销素材(如 banner)。");
+
+ b.Property("Budget")
+ .HasColumnType("numeric")
+ .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("EndAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("结束时间。");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasComment("活动名称。");
+
+ b.Property("PromotionType")
+ .HasColumnType("integer")
+ .HasComment("活动类型。");
+
+ b.Property("RulesJson")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasComment("活动规则 JSON。");
+
+ b.Property("StartAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("开始时间。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .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.ToTable("promotion_campaigns", null, t =>
+ {
+ t.HasComment("营销活动配置。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.ChatMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ChatSessionId")
+ .HasColumnType("bigint")
+ .HasComment("会话标识。");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)")
+ .HasComment("消息内容。");
+
+ b.Property("ContentType")
+ .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("IsRead")
+ .HasColumnType("boolean")
+ .HasComment("是否已读。");
+
+ b.Property("ReadAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("读取时间。");
+
+ b.Property("SenderType")
+ .HasColumnType("integer")
+ .HasComment("发送方类型。");
+
+ b.Property("SenderUserId")
+ .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", "ChatSessionId", "CreatedAt");
+
+ b.ToTable("chat_messages", null, t =>
+ {
+ t.HasComment("会话消息。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.ChatSession", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AgentUserId")
+ .HasColumnType("bigint")
+ .HasComment("当前客服员工 ID。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("CustomerUserId")
+ .HasColumnType("bigint")
+ .HasComment("顾客用户 ID。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("EndedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("结束时间。");
+
+ b.Property("IsBotActive")
+ .HasColumnType("boolean")
+ .HasComment("是否机器人接待中。");
+
+ b.Property("SessionCode")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasComment("会话编号。");
+
+ b.Property("StartedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("开始时间。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasComment("会话状态。");
+
+ b.Property("StoreId")
+ .HasColumnType("bigint")
+ .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", "SessionCode")
+ .IsUnique();
+
+ b.ToTable("chat_sessions", null, t =>
+ {
+ t.HasComment("客服会话。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.SupportTicket", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AssignedAgentId")
+ .HasColumnType("bigint")
+ .HasComment("指派的客服。");
+
+ b.Property("ClosedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("关闭时间。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("CustomerUserId")
+ .HasColumnType("bigint")
+ .HasComment("客户用户 ID。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasComment("工单详情。");
+
+ b.Property("OrderId")
+ .HasColumnType("bigint")
+ .HasComment("关联订单(如有)。");
+
+ b.Property("Priority")
+ .HasColumnType("integer")
+ .HasComment("优先级。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasComment("状态。");
+
+ b.Property("Subject")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasComment("工单主题。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("TicketNo")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .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", "TicketNo")
+ .IsUnique();
+
+ b.ToTable("support_tickets", null, t =>
+ {
+ t.HasComment("客服工单。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.TicketComment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AttachmentsJson")
+ .HasColumnType("text")
+ .HasComment("附件 JSON。");
+
+ b.Property("AuthorUserId")
+ .HasColumnType("bigint")
+ .HasComment("评论人 ID。");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)")
+ .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("IsInternal")
+ .HasColumnType("boolean")
+ .HasComment("是否内部备注。");
+
+ b.Property("SupportTicketId")
+ .HasColumnType("bigint")
+ .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", "SupportTicketId");
+
+ b.ToTable("ticket_comments", null, t =>
+ {
+ t.HasComment("工单评论/流转记录。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Deliveries.Entities.DeliveryEvent", 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("DeliveryOrderId")
+ .HasColumnType("bigint")
+ .HasComment("配送单标识。");
+
+ b.Property("EventType")
+ .HasColumnType("integer")
+ .HasComment("事件类型。");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasComment("事件描述。");
+
+ b.Property("OccurredAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("发生时间。");
+
+ b.Property