refactor: AdminApi 剔除租户侧能力
This commit is contained in:
@@ -31,8 +31,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{EC44
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Authorization", "src\Modules\TakeoutSaaS.Module.Authorization\TakeoutSaaS.Module.Authorization.csproj", "{6CB8487D-5C74-487C-9D84-E57838BDA015}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Tenancy", "src\Modules\TakeoutSaaS.Module.Tenancy\TakeoutSaaS.Module.Tenancy.csproj", "{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Shared.Kernel", "TakeoutSaaS.BuildingBlocks\src\Core\TakeoutSaaS.Shared.Kernel\TakeoutSaaS.Shared.Kernel.csproj", "{BBC99B58-ECA8-42C3-9070-9AA058D778D3}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Storage", "src\Modules\TakeoutSaaS.Module.Storage\TakeoutSaaS.Module.Storage.csproj", "{05058F44-6FB7-43AF-8648-8BF538E283EF}"
|
||||
@@ -47,12 +45,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Schedule
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Sms", "src\Modules\TakeoutSaaS.Module.Sms\TakeoutSaaS.Module.Sms.csproj", "{38011EC3-7EC3-40E4-B9B2-E631966B350B}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Application.Tests", "tests\TakeoutSaaS.Application.Tests\TakeoutSaaS.Application.Tests.csproj", "{2601637E-777A-4FA2-81BA-1AFE32E961FF}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Integration.Tests", "tests\TakeoutSaaS.Integration.Tests\TakeoutSaaS.Integration.Tests.csproj", "{8179CA95-33F8-45F2-BA29-9B1CC7D1E7CB}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -147,18 +139,6 @@ Global
|
||||
{6CB8487D-5C74-487C-9D84-E57838BDA015}.Release|x64.Build.0 = Release|Any CPU
|
||||
{6CB8487D-5C74-487C-9D84-E57838BDA015}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{6CB8487D-5C74-487C-9D84-E57838BDA015}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BBC99B58-ECA8-42C3-9070-9AA058D778D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BBC99B58-ECA8-42C3-9070-9AA058D778D3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BBC99B58-ECA8-42C3-9070-9AA058D778D3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
@@ -243,30 +223,6 @@ Global
|
||||
{38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2601637E-777A-4FA2-81BA-1AFE32E961FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2601637E-777A-4FA2-81BA-1AFE32E961FF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2601637E-777A-4FA2-81BA-1AFE32E961FF}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2601637E-777A-4FA2-81BA-1AFE32E961FF}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2601637E-777A-4FA2-81BA-1AFE32E961FF}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2601637E-777A-4FA2-81BA-1AFE32E961FF}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2601637E-777A-4FA2-81BA-1AFE32E961FF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2601637E-777A-4FA2-81BA-1AFE32E961FF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2601637E-777A-4FA2-81BA-1AFE32E961FF}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2601637E-777A-4FA2-81BA-1AFE32E961FF}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2601637E-777A-4FA2-81BA-1AFE32E961FF}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2601637E-777A-4FA2-81BA-1AFE32E961FF}.Release|x86.Build.0 = Release|Any CPU
|
||||
{8179CA95-33F8-45F2-BA29-9B1CC7D1E7CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8179CA95-33F8-45F2-BA29-9B1CC7D1E7CB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8179CA95-33F8-45F2-BA29-9B1CC7D1E7CB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{8179CA95-33F8-45F2-BA29-9B1CC7D1E7CB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{8179CA95-33F8-45F2-BA29-9B1CC7D1E7CB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{8179CA95-33F8-45F2-BA29-9B1CC7D1E7CB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{8179CA95-33F8-45F2-BA29-9B1CC7D1E7CB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8179CA95-33F8-45F2-BA29-9B1CC7D1E7CB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8179CA95-33F8-45F2-BA29-9B1CC7D1E7CB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{8179CA95-33F8-45F2-BA29-9B1CC7D1E7CB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{8179CA95-33F8-45F2-BA29-9B1CC7D1E7CB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{8179CA95-33F8-45F2-BA29-9B1CC7D1E7CB}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -285,7 +241,6 @@ Global
|
||||
{80B45C7D-9423-400A-8279-40D95BFEBC9D} = {9048EB7F-3875-A59E-E36B-5BD4C6F2A282}
|
||||
{EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{6CB8487D-5C74-487C-9D84-E57838BDA015} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{BBC99B58-ECA8-42C3-9070-9AA058D778D3} = {8D626EA8-CB54-BC41-363A-217881BEBA6E}
|
||||
{05058F44-6FB7-43AF-8648-8BF538E283EF} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{5C12177E-6C25-4F78-BFD4-AA073CFC0650} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
@@ -293,7 +248,5 @@ Global
|
||||
{FE49A9E7-1228-45BA-9B71-337AA353FE98} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{9C2F510E-4054-482D-AFD3-D2E374D60304} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{38011EC3-7EC3-40E4-B9B2-E631966B350B} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{2601637E-777A-4FA2-81BA-1AFE32E961FF} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{8179CA95-33F8-45F2-BA29-9B1CC7D1E7CB} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -13,6 +13,12 @@ public sealed record FileUploadFormRequest
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required IFormFile File { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID(0 表示平台)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public long? TenantId { get; init; }
|
||||
/// <summary>
|
||||
/// 上传类型。
|
||||
/// </summary>
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.AdminApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 应用端公告(面向已认证用户)。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/app/announcements")]
|
||||
public sealed class AppAnnouncementsController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取当前用户可见的公告列表(已发布/有效期内)。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 示例:
|
||||
/// <code>
|
||||
/// GET /api/app/announcements?page=1&pageSize=20
|
||||
/// Header: Authorization: Bearer <JWT>
|
||||
/// 响应:
|
||||
/// {
|
||||
/// "success": true,
|
||||
/// "code": 200,
|
||||
/// "data": {
|
||||
/// "items": [],
|
||||
/// "page": 1,
|
||||
/// "pageSize": 20,
|
||||
/// "totalCount": 0
|
||||
/// }
|
||||
/// }
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
[HttpGet]
|
||||
[SwaggerOperation(Summary = "获取可见公告列表", Description = "仅返回已发布且在有效期内的公告(含平台公告)。")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantAnnouncementDto>>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ApiResponse<PagedResult<TenantAnnouncementDto>>> List([FromQuery] GetTenantsAnnouncementsQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = query with
|
||||
{
|
||||
Status = AnnouncementStatus.Published,
|
||||
IsActive = true,
|
||||
OnlyEffective = true
|
||||
};
|
||||
|
||||
var result = await mediator.Send(request, cancellationToken);
|
||||
return ApiResponse<PagedResult<TenantAnnouncementDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前用户未读公告。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 示例:
|
||||
/// <code>
|
||||
/// GET /api/app/announcements/unread?page=1&pageSize=20
|
||||
/// 响应:
|
||||
/// {
|
||||
/// "success": true,
|
||||
/// "code": 200,
|
||||
/// "data": {
|
||||
/// "items": [],
|
||||
/// "page": 1,
|
||||
/// "pageSize": 20,
|
||||
/// "totalCount": 0
|
||||
/// }
|
||||
/// }
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
[HttpGet("unread")]
|
||||
[SwaggerOperation(Summary = "获取未读公告", Description = "仅返回未读且在有效期内的已发布公告。")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantAnnouncementDto>>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ApiResponse<PagedResult<TenantAnnouncementDto>>> GetUnread([FromQuery] GetUnreadAnnouncementsQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
return ApiResponse<PagedResult<TenantAnnouncementDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标记公告已读。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 示例:
|
||||
/// <code>
|
||||
/// POST /api/app/announcements/900123456789012345/mark-read
|
||||
/// 响应:
|
||||
/// {
|
||||
/// "success": true,
|
||||
/// "code": 200,
|
||||
/// "data": {
|
||||
/// "id": "900123456789012345",
|
||||
/// "isRead": true
|
||||
/// }
|
||||
/// }
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
[HttpPost("{announcementId:long}/mark-read")]
|
||||
[SwaggerOperation(Summary = "标记公告已读", Description = "仅已发布且可见的公告允许标记已读。")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ApiResponse<TenantAnnouncementDto>> MarkRead(long announcementId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new MarkAnnouncementAsReadCommand { AnnouncementId = announcementId }, cancellationToken);
|
||||
return result is null
|
||||
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
|
||||
: ApiResponse<TenantAnnouncementDto>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -154,44 +154,4 @@ public sealed class AuthController(IAdminAuthService authService, IMediator medi
|
||||
var menu = await authService.GetMenuTreeAsync(userId, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<MenuNodeDto>>.Ok(menu);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询指定用户的角色与权限概览(当前租户范围)。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 示例:
|
||||
/// <code>
|
||||
/// GET /api/admin/v1/auth/permissions/900123456789012346
|
||||
/// Header: Authorization: Bearer <JWT>
|
||||
/// 响应:
|
||||
/// {
|
||||
/// "success": true,
|
||||
/// "code": 200,
|
||||
/// "data": {
|
||||
/// "userId": "900123456789012346",
|
||||
/// "tenantId": "100000000000000001",
|
||||
/// "merchantId": "200000000000000001",
|
||||
/// "account": "ops.manager",
|
||||
/// "displayName": "运营经理",
|
||||
/// "roles": ["OpsManager", "Reporter"],
|
||||
/// "permissions": ["delivery:read", "order:read", "payment:read"],
|
||||
/// "createdAt": "2025-12-01T08:30:00Z"
|
||||
/// }
|
||||
/// }
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
/// <param name="userId">目标用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>用户权限概览,未找到则返回 404。</returns>
|
||||
[HttpGet("permissions/{userId:long}")]
|
||||
[PermissionAuthorize("identity:permission:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<UserPermissionDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<UserPermissionDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<UserPermissionDto>> GetUserPermissions(long userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await authService.GetUserPermissionsAsync(userId, cancellationToken);
|
||||
return result is null
|
||||
? ApiResponse<UserPermissionDto>.Error(ErrorCodes.NotFound, "用户不存在或不属于当前租户")
|
||||
: ApiResponse<UserPermissionDto>.Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,19 +34,24 @@ public sealed class FilesController(IFileStorageService fileStorageService) : Ba
|
||||
{
|
||||
return ApiResponse<FileUploadResponse>.Error(ErrorCodes.BadRequest, "文件不能为空");
|
||||
}
|
||||
// 2. 解析上传类型
|
||||
// 2. 校验租户标识
|
||||
if (!request.TenantId.HasValue || request.TenantId.Value < 0)
|
||||
{
|
||||
return ApiResponse<FileUploadResponse>.Error(ErrorCodes.BadRequest, "TenantId 不能为空");
|
||||
}
|
||||
// 3. 解析上传类型
|
||||
if (!UploadFileTypeParser.TryParse(request.Type, out var uploadType))
|
||||
{
|
||||
return ApiResponse<FileUploadResponse>.Error(ErrorCodes.BadRequest, "上传类型不合法");
|
||||
}
|
||||
// 3. 提取请求来源
|
||||
// 4. 提取请求来源
|
||||
var origin = Request.Headers["Origin"].FirstOrDefault() ?? Request.Headers["Referer"].FirstOrDefault();
|
||||
await using var stream = request.File.OpenReadStream();
|
||||
// 4. 调用存储服务执行上传
|
||||
// 5. 调用存储服务执行上传
|
||||
var result = await fileStorageService.UploadAsync(
|
||||
new UploadFileRequest(uploadType, stream, request.File.FileName, request.File.ContentType ?? string.Empty, request.File.Length, origin),
|
||||
new UploadFileRequest(uploadType, request.TenantId.Value, stream, request.File.FileName, request.File.ContentType ?? string.Empty, request.File.Length, origin),
|
||||
cancellationToken);
|
||||
// 5. 返回上传结果
|
||||
// 6. 返回上传结果
|
||||
return ApiResponse<FileUploadResponse>.Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.AdminApi.Controllers;
|
||||
@@ -19,9 +18,8 @@ namespace TakeoutSaaS.AdminApi.Controllers;
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/platform/announcements")]
|
||||
[Route("api/admin/v{version:apiVersion}/platform/announcements")]
|
||||
public sealed class PlatformAnnouncementsController(IMediator mediator, ITenantContextAccessor tenantContextAccessor) : BaseApiController
|
||||
public sealed class PlatformAnnouncementsController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建平台公告。
|
||||
@@ -101,7 +99,7 @@ public sealed class PlatformAnnouncementsController(IMediator mediator, ITenantC
|
||||
public async Task<ApiResponse<PagedResult<TenantAnnouncementDto>>> List([FromQuery] GetTenantsAnnouncementsQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = query with { TenantId = 0 };
|
||||
var result = await ExecuteAsPlatformAsync(() => mediator.Send(request, cancellationToken));
|
||||
var result = await mediator.Send(request, cancellationToken);
|
||||
return ApiResponse<PagedResult<TenantAnnouncementDto>>.Ok(result);
|
||||
}
|
||||
|
||||
@@ -133,8 +131,7 @@ public sealed class PlatformAnnouncementsController(IMediator mediator, ITenantC
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
||||
public async Task<ApiResponse<TenantAnnouncementDto>> Detail(long announcementId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await ExecuteAsPlatformAsync(() =>
|
||||
mediator.Send(new GetAnnouncementByIdQuery { TenantId = 0, AnnouncementId = announcementId }, cancellationToken));
|
||||
var result = await mediator.Send(new GetAnnouncementByIdQuery { TenantId = 0, AnnouncementId = announcementId }, cancellationToken);
|
||||
|
||||
return result is null
|
||||
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
|
||||
@@ -215,8 +212,8 @@ public sealed class PlatformAnnouncementsController(IMediator mediator, ITenantC
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
||||
public async Task<ApiResponse<TenantAnnouncementDto>> Publish(long announcementId, [FromBody, Required] PublishAnnouncementCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
command = command with { AnnouncementId = announcementId };
|
||||
var result = await ExecuteAsPlatformAsync(() => mediator.Send(command, cancellationToken));
|
||||
command = command with { TenantId = 0, AnnouncementId = announcementId };
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
return result is null
|
||||
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
|
||||
@@ -254,25 +251,11 @@ public sealed class PlatformAnnouncementsController(IMediator mediator, ITenantC
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
||||
public async Task<ApiResponse<TenantAnnouncementDto>> Revoke(long announcementId, [FromBody, Required] RevokeAnnouncementCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
command = command with { AnnouncementId = announcementId };
|
||||
var result = await ExecuteAsPlatformAsync(() => mediator.Send(command, cancellationToken));
|
||||
command = command with { TenantId = 0, AnnouncementId = announcementId };
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
return result is null
|
||||
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
|
||||
: ApiResponse<TenantAnnouncementDto>.Ok(result);
|
||||
}
|
||||
|
||||
private async Task<T> ExecuteAsPlatformAsync<T>(Func<Task<T>> action)
|
||||
{
|
||||
var original = tenantContextAccessor.Current;
|
||||
tenantContextAccessor.Current = new TenantContext(0, null, "platform");
|
||||
try
|
||||
{
|
||||
return await action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContextAccessor.Current = original;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.AdminApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 公共租户套餐查询接口。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("public-self-service")]
|
||||
[Route("api/public/v{version:apiVersion}/tenant-packages")]
|
||||
public sealed class PublicTenantPackagesController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 分页获取已启用的租户套餐。
|
||||
/// </summary>
|
||||
/// <param name="query">分页参数。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>启用套餐的分页列表。</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantPackageDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PagedResult<TenantPackageDto>>> List(
|
||||
[FromQuery, Required] GetPublicTenantPackagesQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行查询
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
// 2. 返回结果
|
||||
return ApiResponse<PagedResult<TenantPackageDto>>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.AdminApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 公域租户订阅自助接口(需登录,无权限校验)。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[EnableRateLimiting("public-self-service")]
|
||||
[Route("api/public/v{version:apiVersion}/tenants")]
|
||||
public sealed class PublicTenantSubscriptionsController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 初次绑定租户订阅(默认 0 个月)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="body">绑定请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>绑定后的订阅信息。</returns>
|
||||
[HttpPost("{tenantId:long}/subscriptions/initial")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantSubscriptionDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantSubscriptionDto>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantSubscriptionDto>), StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantSubscriptionDto>), StatusCodes.Status409Conflict)]
|
||||
public async Task<ApiResponse<TenantSubscriptionDto>> BindInitialSubscription(
|
||||
long tenantId,
|
||||
[FromBody, Required] BindInitialTenantSubscriptionCommand body,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 合并路由租户标识
|
||||
var command = body with { TenantId = tenantId };
|
||||
|
||||
// 2. 执行初次订阅绑定
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 3. 返回绑定结果
|
||||
return ApiResponse<TenantSubscriptionDto>.Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.AdminApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 公域租户自助入住接口。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("public-self-service")]
|
||||
[Route("api/public/v{version:apiVersion}/tenants")]
|
||||
public sealed class PublicTenantsController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 自助注册租户并生成初始管理员。
|
||||
/// </summary>
|
||||
/// <param name="command">自助注册命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>注册结果(含临时密码)。</returns>
|
||||
[HttpPost("self-register")]
|
||||
[ProducesResponseType(typeof(ApiResponse<SelfRegisterResultDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<SelfRegisterResultDto>> SelfRegister(
|
||||
[FromBody, Required] SelfRegisterTenantCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行自助注册
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
return ApiResponse<SelfRegisterResultDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 自助提交或更新实名资料。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="command">实名资料。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>实名资料结果。</returns>
|
||||
[HttpPost("{tenantId:long}/verification")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantVerificationDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<TenantVerificationDto>> SubmitVerification(
|
||||
long tenantId,
|
||||
[FromBody, Required] SubmitTenantVerificationCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 绑定租户 ID
|
||||
var merged = command with { TenantId = tenantId };
|
||||
// 2. 提交实名
|
||||
var result = await mediator.Send(merged, cancellationToken);
|
||||
return ApiResponse<TenantVerificationDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询租户入住进度。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>入住进度。</returns>
|
||||
[HttpGet("{tenantId:long}/status")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantProgressDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<TenantProgressDto>> Progress(long tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询进度
|
||||
var query = new GetTenantProgressQuery { TenantId = tenantId };
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
return ApiResponse<TenantProgressDto>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -168,50 +168,4 @@ public sealed class RoleTemplatesController(IMediator mediator) : BaseApiControl
|
||||
return ApiResponse<bool>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为当前租户批量初始化预置角色模板。
|
||||
/// </summary>
|
||||
/// <param name="command">初始化命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>生成的租户角色列表。</returns>
|
||||
[HttpPost("init")]
|
||||
[PermissionAuthorize("identity:role:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RoleDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<RoleDto>>> Initialize(
|
||||
[FromBody] InitializeRoleTemplatesCommand? command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 确保命令存在
|
||||
command ??= new InitializeRoleTemplatesCommand();
|
||||
|
||||
// 2. 初始化模板到租户
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 3. 返回新建的角色列表
|
||||
return ApiResponse<IReadOnlyList<RoleDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将单个模板初始化到当前租户。
|
||||
/// </summary>
|
||||
/// <param name="templateCode">模板编码。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>生成的角色列表。</returns>
|
||||
[HttpPost("{templateCode}/initialize-tenant")]
|
||||
[PermissionAuthorize("identity:role:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RoleDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<RoleDto>>> InitializeSingle(string templateCode, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 构造初始化命令
|
||||
var command = new InitializeRoleTemplatesCommand
|
||||
{
|
||||
TemplateCodes = new[] { templateCode }
|
||||
};
|
||||
|
||||
// 2. 初始化模板到租户
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 3. 返回生成的角色列表
|
||||
return ApiResponse<IReadOnlyList<RoleDto>>.Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ using TakeoutSaaS.Application.App.StoreAudits.Queries;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.AdminApi.Controllers;
|
||||
@@ -18,9 +17,8 @@ namespace TakeoutSaaS.AdminApi.Controllers;
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/platform/store-audits")]
|
||||
[Route("api/admin/v{version:apiVersion}/platform/store-audits")]
|
||||
public sealed class StoreAuditsController(IMediator mediator, ITenantContextAccessor tenantContextAccessor) : BaseApiController
|
||||
public sealed class StoreAuditsController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询待审核门店列表。
|
||||
@@ -34,7 +32,7 @@ public sealed class StoreAuditsController(IMediator mediator, ITenantContextAcce
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询待审核门店列表
|
||||
var result = await ExecuteAsPlatformAsync(() => mediator.Send(query, cancellationToken));
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
|
||||
// 2. 返回分页结果
|
||||
return ApiResponse<PagedResult<PendingStoreAuditDto>>.Ok(result);
|
||||
@@ -53,8 +51,7 @@ public sealed class StoreAuditsController(IMediator mediator, ITenantContextAcce
|
||||
public async Task<ApiResponse<StoreAuditDetailDto>> GetDetail(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取审核详情
|
||||
var result = await ExecuteAsPlatformAsync(() =>
|
||||
mediator.Send(new GetStoreAuditDetailQuery { StoreId = storeId }, cancellationToken));
|
||||
var result = await mediator.Send(new GetStoreAuditDetailQuery { StoreId = storeId }, cancellationToken);
|
||||
|
||||
// 2. 返回详情或未找到
|
||||
return result is null
|
||||
@@ -79,7 +76,7 @@ public sealed class StoreAuditsController(IMediator mediator, ITenantContextAcce
|
||||
{
|
||||
// 1. 执行审核通过
|
||||
var request = command with { StoreId = storeId };
|
||||
var result = await ExecuteAsPlatformAsync(() => mediator.Send(request, cancellationToken));
|
||||
var result = await mediator.Send(request, cancellationToken);
|
||||
|
||||
// 2. 返回结果
|
||||
return ApiResponse<StoreAuditActionResultDto>.Ok(result);
|
||||
@@ -102,7 +99,7 @@ public sealed class StoreAuditsController(IMediator mediator, ITenantContextAcce
|
||||
{
|
||||
// 1. 执行审核驳回
|
||||
var request = command with { StoreId = storeId };
|
||||
var result = await ExecuteAsPlatformAsync(() => mediator.Send(request, cancellationToken));
|
||||
var result = await mediator.Send(request, cancellationToken);
|
||||
|
||||
// 2. 返回结果
|
||||
return ApiResponse<StoreAuditActionResultDto>.Ok(result);
|
||||
@@ -132,7 +129,7 @@ public sealed class StoreAuditsController(IMediator mediator, ITenantContextAcce
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
};
|
||||
var result = await ExecuteAsPlatformAsync(() => mediator.Send(query, cancellationToken));
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
|
||||
// 2. 返回分页结果
|
||||
return ApiResponse<PagedResult<StoreAuditRecordDto>>.Ok(result);
|
||||
@@ -152,7 +149,7 @@ public sealed class StoreAuditsController(IMediator mediator, ITenantContextAcce
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行统计查询
|
||||
var result = await ExecuteAsPlatformAsync(() => mediator.Send(query, cancellationToken));
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
|
||||
// 2. 返回统计结果
|
||||
return ApiResponse<StoreAuditStatisticsDto>.Ok(result);
|
||||
@@ -175,7 +172,7 @@ public sealed class StoreAuditsController(IMediator mediator, ITenantContextAcce
|
||||
{
|
||||
// 1. 执行强制关闭
|
||||
var request = command with { StoreId = storeId };
|
||||
var result = await ExecuteAsPlatformAsync(() => mediator.Send(request, cancellationToken));
|
||||
var result = await mediator.Send(request, cancellationToken);
|
||||
|
||||
// 2. 返回结果
|
||||
return ApiResponse<StoreAuditActionResultDto>.Ok(result);
|
||||
@@ -198,25 +195,9 @@ public sealed class StoreAuditsController(IMediator mediator, ITenantContextAcce
|
||||
{
|
||||
// 1. 执行解除强制关闭
|
||||
var request = command with { StoreId = storeId };
|
||||
var result = await ExecuteAsPlatformAsync(() => mediator.Send(request, cancellationToken));
|
||||
var result = await mediator.Send(request, cancellationToken);
|
||||
|
||||
// 2. 返回结果
|
||||
return ApiResponse<StoreAuditActionResultDto>.Ok(result);
|
||||
}
|
||||
|
||||
private async Task<T> ExecuteAsPlatformAsync<T>(Func<Task<T>> action)
|
||||
{
|
||||
var original = tenantContextAccessor.Current;
|
||||
tenantContextAccessor.Current = new TenantContext(0, null, "platform");
|
||||
|
||||
// 1. (空行后) 切换到平台上下文执行
|
||||
try
|
||||
{
|
||||
return await action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContextAccessor.Current = original;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
using TakeoutSaaS.Application.App.Stores.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;
|
||||
@@ -15,11 +14,9 @@ namespace TakeoutSaaS.AdminApi.Controllers;
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/platform/store-qualifications")]
|
||||
[Route("api/admin/v{version:apiVersion}/platform/store-qualifications")]
|
||||
public sealed class StoreQualificationsController(
|
||||
IMediator mediator,
|
||||
ITenantContextAccessor tenantContextAccessor)
|
||||
IMediator mediator)
|
||||
: BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
@@ -36,25 +33,9 @@ public sealed class StoreQualificationsController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询资质预警
|
||||
var result = await ExecuteAsPlatformAsync(() => mediator.Send(query, cancellationToken));
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
|
||||
// 2. (空行后) 返回结果
|
||||
return ApiResponse<StoreQualificationAlertResultDto>.Ok(result);
|
||||
}
|
||||
|
||||
private async Task<T> ExecuteAsPlatformAsync<T>(Func<Task<T>> action)
|
||||
{
|
||||
var original = tenantContextAccessor.Current;
|
||||
tenantContextAccessor.Current = new TenantContext(0, null, "platform");
|
||||
|
||||
// 1. (空行后) 切换到平台上下文执行
|
||||
try
|
||||
{
|
||||
return await action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContextAccessor.Current = original;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,7 +244,7 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC
|
||||
}
|
||||
|
||||
// 2. (空行后) 发布公告
|
||||
command = command with { AnnouncementId = announcementId };
|
||||
command = command with { TenantId = tenantId, AnnouncementId = announcementId };
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
return result is null
|
||||
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
|
||||
@@ -289,7 +289,7 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC
|
||||
}
|
||||
|
||||
// 2. (空行后) 撤销公告
|
||||
command = command with { AnnouncementId = announcementId };
|
||||
command = command with { TenantId = tenantId, AnnouncementId = announcementId };
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
return result is null
|
||||
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
|
||||
@@ -329,42 +329,4 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC
|
||||
return ApiResponse<bool>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标记公告已读。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 示例:
|
||||
/// <code>
|
||||
/// POST /api/admin/v1/tenants/100000000000000001/announcements/900123456789012345/read
|
||||
/// 响应:
|
||||
/// {
|
||||
/// "success": true,
|
||||
/// "code": 200,
|
||||
/// "data": {
|
||||
/// "id": "900123456789012345",
|
||||
/// "isRead": true
|
||||
/// }
|
||||
/// }
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
[HttpPost("{announcementId:long}/read")]
|
||||
[PermissionAuthorize("tenant-announcement:read")]
|
||||
[SwaggerOperation(Summary = "标记公告已读", Description = "需要权限:tenant-announcement:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
||||
public async Task<ApiResponse<TenantAnnouncementDto>> MarkRead(long tenantId, long announcementId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验租户标识
|
||||
if (tenantId <= 0)
|
||||
{
|
||||
return ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status400BadRequest, "租户标识无效");
|
||||
}
|
||||
|
||||
// 2. (空行后) 标记已读
|
||||
var result = await mediator.Send(new MarkAnnouncementAsReadCommand { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken);
|
||||
return result is null
|
||||
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
|
||||
: ApiResponse<TenantAnnouncementDto>.Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
@@ -348,49 +347,6 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController
|
||||
return ApiResponse<PagedResult<TenantAuditLogDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 伪装登录租户(仅平台超级管理员可用)。
|
||||
/// </summary>
|
||||
/// <returns>目标租户主管理员的令牌对。</returns>
|
||||
[HttpPost("{tenantId:long}/impersonate")]
|
||||
[PermissionAuthorize("tenant:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<TokenResponse>> Impersonate(long tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行伪装登录
|
||||
var result = await mediator.Send(new ImpersonateTenantCommand { TenantId = tenantId }, cancellationToken);
|
||||
|
||||
// 2. 返回令牌
|
||||
return ApiResponse<TokenResponse>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成租户主管理员重置密码链接(仅平台超级管理员可用)。
|
||||
/// </summary>
|
||||
/// <remarks>链接默认 24 小时有效且仅可使用一次。</remarks>
|
||||
/// <returns>重置密码链接。</returns>
|
||||
[HttpPost("{tenantId:long}/admin/reset-link")]
|
||||
[PermissionAuthorize("tenant:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<string>> CreateAdminResetLink(long tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 生成一次性令牌
|
||||
var token = await mediator.Send(new CreateTenantAdminResetLinkTokenCommand { TenantId = tenantId }, cancellationToken);
|
||||
|
||||
// 2. 解析前端来源(优先 Origin,避免拼成 AdminApi 域名)
|
||||
var origin = Request.Headers.Origin.ToString();
|
||||
if (string.IsNullOrWhiteSpace(origin))
|
||||
{
|
||||
origin = $"{Request.Scheme}://{Request.Host}";
|
||||
}
|
||||
|
||||
origin = origin.TrimEnd('/');
|
||||
var resetUrl = $"{origin}/#/auth/reset-password?token={Uri.EscapeDataString(token)}";
|
||||
|
||||
// 3. 返回链接
|
||||
return ApiResponse<string>.Ok(data: resetUrl);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配额校验并占用额度(门店/账号/短信/配送)。
|
||||
/// </summary>
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Application.Identity.Queries;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.AdminApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 用户权限洞察接口。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/users/permissions")]
|
||||
public sealed class UserPermissionsController(IAdminAuthService authService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 分页查询当前租户用户的角色与权限概览。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 示例:
|
||||
/// <code>
|
||||
/// GET /api/admin/v1/users/permissions?keyword=ops&page=1&pageSize=20&sortBy=createdAt&sortDescending=true
|
||||
/// Header: Authorization: Bearer <JWT>
|
||||
/// 响应:
|
||||
/// {
|
||||
/// "success": true,
|
||||
/// "code": 200,
|
||||
/// "data": {
|
||||
/// "items": [
|
||||
/// {
|
||||
/// "userId": "900123456789012346",
|
||||
/// "tenantId": "100000000000000001",
|
||||
/// "merchantId": "200000000000000001",
|
||||
/// "account": "ops.manager",
|
||||
/// "displayName": "运营经理",
|
||||
/// "roles": ["OpsManager", "Reporter"],
|
||||
/// "permissions": ["delivery:read", "order:read", "payment:read"],
|
||||
/// "createdAt": "2025-12-01T08:30:00Z"
|
||||
/// }
|
||||
/// ],
|
||||
/// "page": 1,
|
||||
/// "pageSize": 20,
|
||||
/// "totalCount": 1,
|
||||
/// "totalPages": 1
|
||||
/// }
|
||||
/// }
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
/// <param name="query">搜索条件。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页的用户权限概览。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("identity:permission:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<UserPermissionDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PagedResult<UserPermissionDto>>> Search(
|
||||
[FromQuery] SearchUserPermissionsQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询当前租户的用户权限概览
|
||||
var result = await authService.SearchUserPermissionsAsync(
|
||||
query.Keyword,
|
||||
query.Page,
|
||||
query.PageSize,
|
||||
query.SortBy,
|
||||
query.SortDescending,
|
||||
cancellationToken);
|
||||
|
||||
// 2. 返回分页结果
|
||||
return ApiResponse<PagedResult<UserPermissionDto>>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ COPY ["src/Modules/TakeoutSaaS.Module.Messaging/TakeoutSaaS.Module.Messaging.csp
|
||||
COPY ["src/Modules/TakeoutSaaS.Module.Scheduler/TakeoutSaaS.Module.Scheduler.csproj", "src/Modules/TakeoutSaaS.Module.Scheduler/"]
|
||||
COPY ["src/Modules/TakeoutSaaS.Module.Sms/TakeoutSaaS.Module.Sms.csproj", "src/Modules/TakeoutSaaS.Module.Sms/"]
|
||||
COPY ["src/Modules/TakeoutSaaS.Module.Storage/TakeoutSaaS.Module.Storage.csproj", "src/Modules/TakeoutSaaS.Module.Storage/"]
|
||||
COPY ["src/Modules/TakeoutSaaS.Module.Tenancy/TakeoutSaaS.Module.Tenancy.csproj", "src/Modules/TakeoutSaaS.Module.Tenancy/"]
|
||||
|
||||
RUN dotnet restore "src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj"
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.AdminApi.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// 路由租户上下文过滤器:当路由中包含 tenantId 时,将其注入租户上下文,便于下游使用 <see cref="ITenantProvider"/> 与 EF 租户过滤器。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 初始化过滤器。
|
||||
/// </remarks>
|
||||
/// <param name="tenantContextAccessor">租户上下文访问器。</param>
|
||||
public sealed class TenantRouteContextFilter(ITenantContextAccessor tenantContextAccessor) : IAsyncActionFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// 在 Action 执行前将路由 tenantId 写入租户上下文,并在结束后恢复。
|
||||
/// </summary>
|
||||
/// <param name="context">Action 执行上下文。</param>
|
||||
/// <param name="next">下一个执行委托。</param>
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
// 1. 解析路由 tenantId(仅当 > 0 才视为有效)
|
||||
if (!TryGetRouteTenantId(context, out var tenantId))
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. (空行后) 备份并覆盖租户上下文
|
||||
var original = tenantContextAccessor.Current;
|
||||
var current = new TenantContext(tenantId, null, "route:tenantId");
|
||||
tenantContextAccessor.Current = current;
|
||||
context.HttpContext.Items[TenantConstants.HttpContextItemKey] = current;
|
||||
|
||||
// 3. (空行后) 执行后续管道并在 finally 中恢复
|
||||
try
|
||||
{
|
||||
await next();
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContextAccessor.Current = original;
|
||||
|
||||
if (original is null)
|
||||
{
|
||||
context.HttpContext.Items.Remove(TenantConstants.HttpContextItemKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.HttpContext.Items[TenantConstants.HttpContextItemKey] = original;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetRouteTenantId(ActionExecutingContext context, out long tenantId)
|
||||
{
|
||||
if (!context.RouteData.Values.TryGetValue("tenantId", out var value) || value is null)
|
||||
{
|
||||
tenantId = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
return long.TryParse(value.ToString(), out tenantId) && tenantId > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Cors.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Serilog;
|
||||
using System.Threading.RateLimiting;
|
||||
using TakeoutSaaS.AdminApi.Filters;
|
||||
using TakeoutSaaS.Application.App.Extensions;
|
||||
using TakeoutSaaS.Application.Identity.Extensions;
|
||||
using TakeoutSaaS.Application.Messaging.Extensions;
|
||||
using TakeoutSaaS.Application.Sms.Extensions;
|
||||
using TakeoutSaaS.Application.Storage.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.App.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Extensions;
|
||||
@@ -22,7 +18,6 @@ using TakeoutSaaS.Module.Messaging.Extensions;
|
||||
using TakeoutSaaS.Module.Scheduler.Extensions;
|
||||
using TakeoutSaaS.Module.Sms.Extensions;
|
||||
using TakeoutSaaS.Module.Storage.Extensions;
|
||||
using TakeoutSaaS.Module.Tenancy.Extensions;
|
||||
using TakeoutSaaS.Shared.Web.Extensions;
|
||||
using TakeoutSaaS.Shared.Web.Swagger;
|
||||
|
||||
@@ -63,13 +58,6 @@ if (isDevelopment)
|
||||
});
|
||||
}
|
||||
|
||||
// 4.1 (空行后) 路由租户注入:以路由 tenantId 写入租户上下文
|
||||
builder.Services.AddScoped<TenantRouteContextFilter>();
|
||||
builder.Services.Configure<MvcOptions>(options =>
|
||||
{
|
||||
options.Filters.AddService<TenantRouteContextFilter>();
|
||||
});
|
||||
|
||||
// 5. 注册领域与基础设施模块
|
||||
builder.Services.AddIdentityApplication();
|
||||
builder.Services.AddIdentityInfrastructure(builder.Configuration, enableAdminSeed: true);
|
||||
@@ -78,12 +66,10 @@ builder.Services.AddAppApplication();
|
||||
builder.Services.AddJwtAuthentication(builder.Configuration);
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddPermissionAuthorization();
|
||||
builder.Services.AddTenantResolution(builder.Configuration);
|
||||
builder.Services.AddDictionaryModule(builder.Configuration);
|
||||
builder.Services.AddStorageModule(builder.Configuration);
|
||||
builder.Services.AddStorageApplication();
|
||||
builder.Services.AddSmsModule(builder.Configuration);
|
||||
builder.Services.AddSmsApplication(builder.Configuration);
|
||||
builder.Services.AddMessagingModule(builder.Configuration);
|
||||
builder.Services.AddMessagingApplication();
|
||||
builder.Services.AddOperationLogOutbox(builder.Configuration);
|
||||
@@ -92,13 +78,6 @@ builder.Services.AddHealthChecks();
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.AddFixedWindowLimiter("public-self-service", limiterOptions =>
|
||||
{
|
||||
limiterOptions.PermitLimit = 10;
|
||||
limiterOptions.Window = TimeSpan.FromMinutes(1);
|
||||
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||
limiterOptions.QueueLimit = 2;
|
||||
});
|
||||
});
|
||||
|
||||
// 6. 配置 OpenTelemetry 采集
|
||||
@@ -167,9 +146,6 @@ app.UseCors("AdminApiCors");
|
||||
app.UseRateLimiter();
|
||||
app.UseSharedWebCore();
|
||||
app.UseAuthentication();
|
||||
|
||||
// 8.1 (空行后) 解析租户:在认证后才能读取 Token Claim(tenant_id)
|
||||
app.UseTenantResolution();
|
||||
app.UseAuthorization();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
|
||||
@@ -35,6 +35,5 @@
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Sms\TakeoutSaaS.Module.Sms.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Scheduler\TakeoutSaaS.Module.Scheduler.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Storage\TakeoutSaaS.Module.Storage.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Tenancy\TakeoutSaaS.Module.Tenancy.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -4,9 +4,9 @@ using TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Domain.Inventory.Entities;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Domain.Stores.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
/// </summary>
|
||||
public sealed class AdjustInventoryCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
IStoreRepository storeRepository,
|
||||
ILogger<AdjustInventoryCommandHandler> logger)
|
||||
: IRequestHandler<AdjustInventoryCommand, InventoryItemDto>
|
||||
{
|
||||
@@ -23,9 +23,10 @@ public sealed class AdjustInventoryCommandHandler(
|
||||
public async Task<InventoryItemDto> Handle(AdjustInventoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取库存
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
var item = await inventoryRepository.GetForUpdateAsync(null, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
|
||||
// 2. 初始化或校验存在性
|
||||
long tenantId;
|
||||
if (item is null)
|
||||
{
|
||||
if (request.QuantityDelta < 0)
|
||||
@@ -33,6 +34,14 @@ public sealed class AdjustInventoryCommandHandler(
|
||||
throw new BusinessException(ErrorCodes.NotFound, "库存不存在,无法扣减");
|
||||
}
|
||||
|
||||
// 2.1 查询门店以获取 TenantId
|
||||
var store = await storeRepository.FindByIdAsync(request.StoreId, null, cancellationToken);
|
||||
if (store is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "门店不存在");
|
||||
}
|
||||
tenantId = store.TenantId;
|
||||
|
||||
// 初始化库存记录
|
||||
item = new InventoryItem
|
||||
{
|
||||
@@ -46,6 +55,10 @@ public sealed class AdjustInventoryCommandHandler(
|
||||
};
|
||||
await inventoryRepository.AddItemAsync(item, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
tenantId = item.TenantId;
|
||||
}
|
||||
|
||||
// 3. 应用调整
|
||||
var newQuantity = item.QuantityOnHand + request.QuantityDelta;
|
||||
|
||||
@@ -5,7 +5,6 @@ using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
@@ -14,7 +13,6 @@ namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
/// </summary>
|
||||
public sealed class DeductInventoryCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<DeductInventoryCommandHandler> logger)
|
||||
: IRequestHandler<DeductInventoryCommand, InventoryItemDto>
|
||||
{
|
||||
@@ -22,12 +20,12 @@ public sealed class DeductInventoryCommandHandler(
|
||||
public async Task<InventoryItemDto> Handle(DeductInventoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取库存
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
var item = await inventoryRepository.GetForUpdateAsync(null, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "库存不存在");
|
||||
}
|
||||
var tenantId = item.TenantId;
|
||||
|
||||
// 1.1 幂等:若锁记录已扣减/释放则直接返回
|
||||
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
|
||||
|
||||
@@ -2,7 +2,6 @@ using MediatR;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Application.App.Inventory.Queries;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
@@ -10,16 +9,14 @@ namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
/// 库存批次查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetInventoryBatchesQueryHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
IInventoryRepository inventoryRepository)
|
||||
: IRequestHandler<GetInventoryBatchesQuery, IReadOnlyList<InventoryBatchDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<InventoryBatchDto>> Handle(GetInventoryBatchesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取批次
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var batches = await inventoryRepository.GetBatchesAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
var batches = await inventoryRepository.GetBatchesAsync(null, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
// 2. 映射
|
||||
return batches.Select(InventoryMapping.ToDto).ToList();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ using MediatR;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Application.App.Inventory.Queries;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
@@ -10,16 +9,14 @@ namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
/// 查询库存处理器。
|
||||
/// </summary>
|
||||
public sealed class GetInventoryItemQueryHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
IInventoryRepository inventoryRepository)
|
||||
: IRequestHandler<GetInventoryItemQuery, InventoryItemDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<InventoryItemDto?> Handle(GetInventoryItemQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取库存
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var item = await inventoryRepository.FindBySkuAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
var item = await inventoryRepository.FindBySkuAsync(null, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
// 2. 返回 DTO
|
||||
return item is null ? null : InventoryMapping.ToDto(item);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
@@ -14,7 +13,6 @@ namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
/// </summary>
|
||||
public sealed class LockInventoryCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<LockInventoryCommandHandler> logger)
|
||||
: IRequestHandler<LockInventoryCommand, InventoryItemDto>
|
||||
{
|
||||
@@ -22,12 +20,12 @@ public sealed class LockInventoryCommandHandler(
|
||||
public async Task<InventoryItemDto> Handle(LockInventoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取库存
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
var item = await inventoryRepository.GetForUpdateAsync(null, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "库存不存在");
|
||||
}
|
||||
var tenantId = item.TenantId;
|
||||
|
||||
// 1.1 幂等处理
|
||||
var existingLock = await inventoryRepository.FindLockByKeyAsync(tenantId, request.IdempotencyKey, cancellationToken);
|
||||
|
||||
@@ -3,7 +3,6 @@ using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
using TakeoutSaaS.Domain.Inventory.Enums;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
@@ -12,7 +11,6 @@ namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
/// </summary>
|
||||
public sealed class ReleaseExpiredInventoryLocksCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<ReleaseExpiredInventoryLocksCommandHandler> logger)
|
||||
: IRequestHandler<ReleaseExpiredInventoryLocksCommand, int>
|
||||
{
|
||||
@@ -20,9 +18,8 @@ public sealed class ReleaseExpiredInventoryLocksCommandHandler(
|
||||
public async Task<int> Handle(ReleaseExpiredInventoryLocksCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询过期锁
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var now = DateTime.UtcNow;
|
||||
var expiredLocks = await inventoryRepository.FindExpiredLocksAsync(tenantId, now, cancellationToken);
|
||||
var expiredLocks = await inventoryRepository.FindExpiredLocksAsync(null, now, cancellationToken);
|
||||
if (expiredLocks.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
@@ -32,7 +29,7 @@ public sealed class ReleaseExpiredInventoryLocksCommandHandler(
|
||||
var affected = 0;
|
||||
foreach (var lockRecord in expiredLocks)
|
||||
{
|
||||
var item = await inventoryRepository.GetForUpdateAsync(tenantId, lockRecord.StoreId, lockRecord.ProductSkuId, cancellationToken);
|
||||
var item = await inventoryRepository.GetForUpdateAsync(lockRecord.TenantId, lockRecord.StoreId, lockRecord.ProductSkuId, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
continue;
|
||||
|
||||
@@ -5,7 +5,6 @@ using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
@@ -14,7 +13,6 @@ namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
/// </summary>
|
||||
public sealed class ReleaseInventoryCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<ReleaseInventoryCommandHandler> logger)
|
||||
: IRequestHandler<ReleaseInventoryCommand, InventoryItemDto>
|
||||
{
|
||||
@@ -22,12 +20,12 @@ public sealed class ReleaseInventoryCommandHandler(
|
||||
public async Task<InventoryItemDto> Handle(ReleaseInventoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取库存
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
var item = await inventoryRepository.GetForUpdateAsync(null, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "库存不存在");
|
||||
}
|
||||
var tenantId = item.TenantId;
|
||||
|
||||
// 1.1 幂等处理:若提供键且锁记录不存在,直接视为已释放
|
||||
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
|
||||
|
||||
@@ -6,7 +6,6 @@ using TakeoutSaaS.Domain.Inventory.Entities;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
@@ -15,7 +14,6 @@ namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
/// </summary>
|
||||
public sealed class UpsertInventoryBatchCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<UpsertInventoryBatchCommandHandler> logger)
|
||||
: IRequestHandler<UpsertInventoryBatchCommand, InventoryBatchDto>
|
||||
{
|
||||
@@ -23,14 +21,21 @@ public sealed class UpsertInventoryBatchCommandHandler(
|
||||
public async Task<InventoryBatchDto> Handle(UpsertInventoryBatchCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取批次
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var batch = await inventoryRepository.GetBatchForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, request.BatchNumber, cancellationToken);
|
||||
var batch = await inventoryRepository.GetBatchForUpdateAsync(null, request.StoreId, request.ProductSkuId, request.BatchNumber, cancellationToken);
|
||||
|
||||
// 2. 创建或更新
|
||||
if (batch is null)
|
||||
{
|
||||
// 2.1 查询库存以获取 TenantId
|
||||
var item = await inventoryRepository.FindBySkuAsync(null, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "库存不存在,无法新增批次");
|
||||
}
|
||||
|
||||
batch = new InventoryBatch
|
||||
{
|
||||
TenantId = tenantId,
|
||||
TenantId = item.TenantId,
|
||||
StoreId = request.StoreId,
|
||||
ProductSkuId = request.ProductSkuId,
|
||||
BatchNumber = request.BatchNumber,
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 生成租户主管理员重置链接令牌命令(平台超级管理员使用)。
|
||||
/// </summary>
|
||||
public sealed record CreateTenantAdminResetLinkTokenCommand : IRequest<string>
|
||||
{
|
||||
/// <summary>
|
||||
/// 目标租户 ID。
|
||||
/// </summary>
|
||||
public required long TenantId { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 伪装登录租户命令(平台超级管理员使用)。
|
||||
/// </summary>
|
||||
public sealed record ImpersonateTenantCommand : IRequest<TokenResponse>
|
||||
{
|
||||
/// <summary>
|
||||
/// 目标租户 ID。
|
||||
/// </summary>
|
||||
public required long TenantId { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 标记公告已读命令。
|
||||
/// </summary>
|
||||
public sealed record MarkAnnouncementAsReadCommand : IRequest<TenantAnnouncementDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法,兼容旧调用,实际以当前租户为准)。
|
||||
/// </summary>
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 公告 ID。
|
||||
/// </summary>
|
||||
[Range(1, long.MaxValue)]
|
||||
public long AnnouncementId { get; init; }
|
||||
}
|
||||
@@ -9,6 +9,12 @@ namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
/// </summary>
|
||||
public sealed record PublishAnnouncementCommand : IRequest<TenantAnnouncementDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(0 表示平台公告)。
|
||||
/// </summary>
|
||||
[Range(0, long.MaxValue)]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 公告 ID。
|
||||
/// </summary>
|
||||
|
||||
@@ -9,6 +9,12 @@ namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
/// </summary>
|
||||
public sealed record RevokeAnnouncementCommand : IRequest<TenantAnnouncementDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(0 表示平台公告)。
|
||||
/// </summary>
|
||||
[Range(0, long.MaxValue)]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 公告 ID。
|
||||
/// </summary>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 自助注册租户命令。
|
||||
/// </summary>
|
||||
public sealed record SelfRegisterTenantCommand : IRequest<SelfRegisterResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始管理员账号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(64)]
|
||||
[RegularExpression("^[A-Za-z0-9]+$", ErrorMessage = "登录账号仅允许大小写字母和数字")]
|
||||
public string AdminAccount { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 初始管理员展示名称。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? AdminDisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 初始管理员邮箱。
|
||||
/// </summary>
|
||||
[EmailAddress]
|
||||
[StringLength(128)]
|
||||
public string? AdminEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 初始管理员手机号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(32)]
|
||||
public string AdminPhone { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 初始管理员登录密码(前端自定义)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(128, MinimumLength = 8)]
|
||||
public string AdminPassword { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 自助注册结果 DTO。
|
||||
/// </summary>
|
||||
public sealed class SelfRegisterResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 初始状态。
|
||||
/// </summary>
|
||||
public TenantStatus Status { get; init; } = TenantStatus.PendingReview;
|
||||
|
||||
/// <summary>
|
||||
/// 当前实名状态。
|
||||
/// </summary>
|
||||
public TenantVerificationStatus VerificationStatus { get; init; } = TenantVerificationStatus.Draft;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅开始时间。
|
||||
/// </summary>
|
||||
public DateTime? EffectiveFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订阅到期时间。
|
||||
/// </summary>
|
||||
public DateTime? EffectiveTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 初始管理员账号。
|
||||
/// </summary>
|
||||
public string AdminAccount { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 租户入住进度 DTO。
|
||||
/// </summary>
|
||||
public sealed class TenantProgressDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 当前租户状态。
|
||||
/// </summary>
|
||||
public TenantStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 实名审核状态。
|
||||
/// </summary>
|
||||
public TenantVerificationStatus VerificationStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前订阅开始时间。
|
||||
/// </summary>
|
||||
public DateTime? EffectiveFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前订阅到期时间。
|
||||
/// </summary>
|
||||
public DateTime? EffectiveTo { get; init; }
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 生成租户主管理员重置链接令牌处理器(平台超级管理员使用)。
|
||||
/// </summary>
|
||||
public sealed class CreateTenantAdminResetLinkTokenCommandHandler(
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IAdminPasswordResetTokenStore tokenStore)
|
||||
: IRequestHandler<CreateTenantAdminResetLinkTokenCommand, string>
|
||||
{
|
||||
private const long PlatformRootTenantId = 1000000000001;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> Handle(CreateTenantAdminResetLinkTokenCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验仅允许平台超级管理员执行
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (currentTenantId != PlatformRootTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅平台超级管理员可生成重置链接");
|
||||
}
|
||||
|
||||
// 2. 校验租户存在且存在主管理员
|
||||
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
|
||||
|
||||
// 2.1 若缺少主管理员则自动回填(兼容历史数据)
|
||||
if (!tenant.PrimaryOwnerUserId.HasValue || tenant.PrimaryOwnerUserId.Value == 0)
|
||||
{
|
||||
var originalContextForFix = tenantContextAccessor.Current;
|
||||
tenantContextAccessor.Current = new TenantContext(tenant.Id, tenant.Code, "admin:reset-link:fix-owner");
|
||||
try
|
||||
{
|
||||
var users = await identityUserRepository.SearchAsync(tenant.Id, keyword: null, cancellationToken);
|
||||
var ownerCandidate = users.OrderBy(x => x.CreatedAt).FirstOrDefault();
|
||||
if (ownerCandidate == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "该租户未配置主管理员账号,且未找到可用管理员账号");
|
||||
}
|
||||
|
||||
tenant.PrimaryOwnerUserId = ownerCandidate.Id;
|
||||
await tenantRepository.UpdateTenantAsync(tenant, cancellationToken);
|
||||
await tenantRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContextAccessor.Current = originalContextForFix;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 签发一次性重置令牌(默认 24 小时有效)
|
||||
var token = await tokenStore.IssueAsync(tenant.PrimaryOwnerUserId.Value, DateTime.UtcNow.AddHours(24), cancellationToken);
|
||||
|
||||
// 4. 写入审计日志
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? $"user:{currentUserAccessor.UserId}"
|
||||
: operatorProfile.DisplayName;
|
||||
|
||||
var auditLog = new TenantAuditLog
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
Action = TenantAuditAction.AdminResetLinkIssued,
|
||||
Title = "生成重置链接",
|
||||
Description = $"操作者:{operatorName},目标用户ID:{tenant.PrimaryOwnerUserId.Value}",
|
||||
OperatorId = currentUserAccessor.UserId,
|
||||
OperatorName = operatorName,
|
||||
PreviousStatus = tenant.Status,
|
||||
CurrentStatus = tenant.Status
|
||||
};
|
||||
await tenantRepository.AddAuditLogAsync(auditLog, cancellationToken);
|
||||
await tenantRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 5. 返回令牌
|
||||
return token;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
@@ -25,11 +24,11 @@ public sealed class CreateTenantManuallyCommandHandler(
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantPackageRepository tenantPackageRepository,
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IPasswordHasher<IdentityUser> passwordHasher,
|
||||
IIdGenerator idGenerator,
|
||||
IMediator mediator,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
ILogger<CreateTenantManuallyCommandHandler> logger)
|
||||
: IRequestHandler<CreateTenantManuallyCommand, TenantDetailDto>
|
||||
@@ -208,56 +207,44 @@ public sealed class CreateTenantManuallyCommandHandler(
|
||||
await tenantRepository.UpsertVerificationProfileAsync(verification, cancellationToken);
|
||||
await tenantRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 12. 临时切换租户上下文,保证身份与权限写入正确
|
||||
var previousContext = tenantContextAccessor.Current;
|
||||
tenantContextAccessor.Current = new TenantContext(tenant.Id, tenant.Code, "manual-create");
|
||||
try
|
||||
// 12. 创建租户管理员账号(Portal=Tenant)
|
||||
var adminUser = new IdentityUser
|
||||
{
|
||||
// 13. 创建租户管理员账号
|
||||
var adminUser = new IdentityUser
|
||||
{
|
||||
Portal = PortalType.Tenant,
|
||||
TenantId = tenant.Id,
|
||||
Account = normalizedAccount,
|
||||
DisplayName = request.AdminDisplayName.Trim(),
|
||||
PasswordHash = string.Empty,
|
||||
Phone = string.IsNullOrWhiteSpace(request.ContactPhone) ? null : request.ContactPhone.Trim(),
|
||||
Email = string.IsNullOrWhiteSpace(request.ContactEmail) ? null : request.ContactEmail.Trim(),
|
||||
MerchantId = request.AdminMerchantId,
|
||||
Avatar = request.AdminAvatar
|
||||
};
|
||||
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, request.AdminPassword);
|
||||
await identityUserRepository.AddAsync(adminUser, cancellationToken);
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
Portal = PortalType.Tenant,
|
||||
TenantId = tenant.Id,
|
||||
Account = normalizedAccount,
|
||||
DisplayName = request.AdminDisplayName.Trim(),
|
||||
PasswordHash = string.Empty,
|
||||
Phone = string.IsNullOrWhiteSpace(request.ContactPhone) ? null : request.ContactPhone.Trim(),
|
||||
Email = string.IsNullOrWhiteSpace(request.ContactEmail) ? null : request.ContactEmail.Trim(),
|
||||
MerchantId = request.AdminMerchantId,
|
||||
Avatar = request.AdminAvatar
|
||||
};
|
||||
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, request.AdminPassword);
|
||||
await identityUserRepository.AddAsync(adminUser, cancellationToken);
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 14. 初始化租户管理员角色模板并绑定角色
|
||||
await mediator.Send(new InitializeRoleTemplatesCommand
|
||||
{
|
||||
TemplateCodes = new[] { "tenant-admin" }
|
||||
}, cancellationToken);
|
||||
|
||||
var tenantAdminRole = await roleRepository.FindByCodeAsync(PortalType.Tenant, tenant.Id, "tenant-admin", cancellationToken);
|
||||
if (tenantAdminRole != null)
|
||||
{
|
||||
await mediator.Send(new AssignUserRolesCommand
|
||||
{
|
||||
UserId = adminUser.Id,
|
||||
RoleIds = new[] { tenantAdminRole.Id }
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
// 15. 回写租户所有者账号
|
||||
tenant.PrimaryOwnerUserId = adminUser.Id;
|
||||
await tenantRepository.UpdateTenantAsync(tenant, cancellationToken);
|
||||
await tenantRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
// 13. 初始化租户管理员角色并绑定到用户(不依赖租户上下文)
|
||||
await mediator.Send(new CopyRoleTemplateCommand
|
||||
{
|
||||
// 16. 恢复上下文
|
||||
tenantContextAccessor.Current = previousContext;
|
||||
Portal = PortalType.Tenant,
|
||||
TenantId = tenant.Id,
|
||||
TemplateCode = "tenant-admin"
|
||||
}, cancellationToken);
|
||||
|
||||
var tenantAdminRole = await roleRepository.FindByCodeAsync(PortalType.Tenant, tenant.Id, "tenant-admin", cancellationToken);
|
||||
if (tenantAdminRole != null)
|
||||
{
|
||||
await userRoleRepository.ReplaceUserRolesAsync(PortalType.Tenant, tenant.Id, adminUser.Id, new[] { tenantAdminRole.Id }, cancellationToken);
|
||||
await userRoleRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// 17. 返回创建结果
|
||||
// 14. 回写租户所有者账号
|
||||
tenant.PrimaryOwnerUserId = adminUser.Id;
|
||||
await tenantRepository.UpdateTenantAsync(tenant, cancellationToken);
|
||||
await tenantRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 15. 返回创建结果
|
||||
logger.LogInformation("已后台手动创建租户 {TenantCode}", tenant.Code);
|
||||
|
||||
return new TenantDetailDto
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
using TakeoutSaaS.Application.App.Tenants.Targeting;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
@@ -13,12 +9,7 @@ namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
/// 公告详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetAnnouncementByIdQueryHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ITenantAnnouncementReadRepository readRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IAdminAuthService? adminAuthService = null,
|
||||
IMiniAuthService? miniAuthService = null)
|
||||
ITenantAnnouncementRepository announcementRepository)
|
||||
: IRequestHandler<GetAnnouncementByIdQuery, TenantAnnouncementDto?>
|
||||
{
|
||||
/// <summary>
|
||||
@@ -29,43 +20,14 @@ public sealed class GetAnnouncementByIdQueryHandler(
|
||||
/// <returns>公告 DTO 或 null。</returns>
|
||||
public async Task<TenantAnnouncementDto?> Handle(GetAnnouncementByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
|
||||
// 1. 查询公告主体(含平台公告)
|
||||
var announcement = await announcementRepository.FindByIdInScopeAsync(tenantId, request.AnnouncementId, cancellationToken);
|
||||
// 1. 查询公告主体
|
||||
var announcement = await announcementRepository.FindByIdAsync(request.TenantId, request.AnnouncementId, cancellationToken);
|
||||
if (announcement == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 目标受众过滤
|
||||
var targetContext = await AnnouncementTargetContextFactory.BuildAsync(
|
||||
tenantProvider,
|
||||
currentUserAccessor,
|
||||
adminAuthService,
|
||||
miniAuthService,
|
||||
cancellationToken);
|
||||
|
||||
if (!TargetTypeFilter.IsMatch(announcement, targetContext))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 优先查用户级已读
|
||||
var userId = targetContext.UserId;
|
||||
var reads = await readRepository.GetByAnnouncementAsync(
|
||||
tenantId,
|
||||
new[] { announcement.Id },
|
||||
userId == 0 ? null : userId,
|
||||
cancellationToken);
|
||||
|
||||
if (reads.Count == 0)
|
||||
{
|
||||
var tenantReads = await readRepository.GetByAnnouncementAsync(tenantId, new[] { announcement.Id }, null, cancellationToken);
|
||||
reads = tenantReads;
|
||||
}
|
||||
|
||||
var readRecord = reads.FirstOrDefault();
|
||||
return announcement.ToDto(readRecord != null, readRecord?.ReadAt);
|
||||
// 2. (空行后) 映射 DTO(管理端不返回已读信息)
|
||||
return announcement.ToDto(false, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 公共场景分页查询启用套餐处理器。
|
||||
/// </summary>
|
||||
public sealed class GetPublicTenantPackagesQueryHandler(ITenantPackageRepository packageRepository)
|
||||
: IRequestHandler<GetPublicTenantPackagesQuery, PagedResult<TenantPackageDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<TenantPackageDto>> Handle(GetPublicTenantPackagesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 仅查询公共可选购套餐(已发布 + 对外可见 + 允许新购 + 启用)
|
||||
var packages = await packageRepository.SearchPublicPurchasableAsync(cancellationToken);
|
||||
// 2. 规范化分页参数
|
||||
var pageIndex = request.Page <= 0 ? 1 : request.Page;
|
||||
var size = request.PageSize <= 0 ? 20 : request.PageSize;
|
||||
// 3. 执行排序、分页与映射
|
||||
var ordered = packages
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenByDescending(x => x.CreatedAt)
|
||||
.ToList();
|
||||
var items = ordered
|
||||
.Skip((pageIndex - 1) * size)
|
||||
.Take(size)
|
||||
.Select(x => x.ToDto())
|
||||
.ToList();
|
||||
// 4. 返回分页结果
|
||||
return new PagedResult<TenantPackageDto>(items, pageIndex, size, ordered.Count);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 租户入住进度查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetTenantProgressQueryHandler(ITenantRepository tenantRepository)
|
||||
: IRequestHandler<GetTenantProgressQuery, TenantProgressDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<TenantProgressDto> Handle(GetTenantProgressQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询租户
|
||||
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
|
||||
|
||||
// 2. 查询订阅与实名
|
||||
var subscription = await tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken);
|
||||
var verification = await tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken);
|
||||
|
||||
// 3. 组装进度信息
|
||||
return new TenantProgressDto
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
Code = tenant.Code,
|
||||
Status = tenant.Status,
|
||||
VerificationStatus = verification?.Status ?? TenantVerificationStatus.Draft,
|
||||
EffectiveFrom = subscription?.EffectiveFrom ?? tenant.EffectiveFrom,
|
||||
EffectiveTo = subscription?.EffectiveTo ?? tenant.EffectiveTo
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
using TakeoutSaaS.Application.App.Tenants.Targeting;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
@@ -14,12 +10,7 @@ namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
/// 公告分页查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetTenantsAnnouncementsQueryHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ITenantAnnouncementReadRepository announcementReadRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IAdminAuthService? adminAuthService = null,
|
||||
IMiniAuthService? miniAuthService = null)
|
||||
ITenantAnnouncementRepository announcementRepository)
|
||||
: IRequestHandler<GetTenantsAnnouncementsQuery, PagedResult<TenantAnnouncementDto>>
|
||||
{
|
||||
/// <summary>
|
||||
@@ -30,7 +21,7 @@ public sealed class GetTenantsAnnouncementsQueryHandler(
|
||||
/// <returns>分页结果。</returns>
|
||||
public async Task<PagedResult<TenantAnnouncementDto>> Handle(GetTenantsAnnouncementsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var tenantId = request.TenantId;
|
||||
var effectiveAt = request.OnlyEffective == true ? DateTime.UtcNow : (DateTime?)null;
|
||||
|
||||
// 计算分页参数
|
||||
@@ -64,62 +55,20 @@ public sealed class GetTenantsAnnouncementsQueryHandler(
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// 3. 目标受众过滤(在内存中,但数据量已大幅减少)
|
||||
var targetContext = await AnnouncementTargetContextFactory.BuildAsync(
|
||||
tenantProvider,
|
||||
currentUserAccessor,
|
||||
adminAuthService,
|
||||
miniAuthService,
|
||||
cancellationToken);
|
||||
|
||||
// 3. 按租户隔离(仅返回 request.TenantId 对应的公告)
|
||||
var filtered = announcements
|
||||
.Where(a => TargetTypeFilter.IsMatch(a, targetContext))
|
||||
.Where(x => x.TenantId == tenantId)
|
||||
.ToList();
|
||||
|
||||
// 注意:由于目标受众过滤可能移除记录,filtered.Count 可能小于请求的 size
|
||||
// 这是可接受的,因为精确计算总数代价高昂
|
||||
|
||||
// 4. 分页(数据已在数据库层排序,这里只需 Skip/Take)
|
||||
var pageItems = filtered
|
||||
.Skip((page - 1) * size)
|
||||
.Take(size)
|
||||
.ToList();
|
||||
|
||||
// 5. 构建已读映射
|
||||
var announcementIds = pageItems.Select(x => x.Id).ToArray();
|
||||
var userId = targetContext.UserId;
|
||||
|
||||
var readMap = new Dictionary<long, (bool isRead, DateTime? readAt)>();
|
||||
if (announcementIds.Length > 0)
|
||||
{
|
||||
var reads = new List<Domain.Tenants.Entities.TenantAnnouncementRead>();
|
||||
if (userId != 0)
|
||||
{
|
||||
var userReads = await announcementReadRepository.GetByAnnouncementAsync(tenantId, announcementIds, userId, cancellationToken);
|
||||
reads.AddRange(userReads);
|
||||
}
|
||||
|
||||
var tenantReads = await announcementReadRepository.GetByAnnouncementAsync(tenantId, announcementIds, null, cancellationToken);
|
||||
reads.AddRange(tenantReads);
|
||||
|
||||
foreach (var read in reads.OrderByDescending(x => x.ReadAt))
|
||||
{
|
||||
if (readMap.ContainsKey(read.AnnouncementId) && read.UserId.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
readMap[read.AnnouncementId] = (true, read.ReadAt);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 映射 DTO 并带上已读状态
|
||||
// 5. 映射 DTO
|
||||
var items = pageItems
|
||||
.Select(a =>
|
||||
{
|
||||
readMap.TryGetValue(a.Id, out var read);
|
||||
return a.ToDto(read.isRead, read.readAt);
|
||||
})
|
||||
.Select(a => a.ToDto(false, null))
|
||||
.ToList();
|
||||
|
||||
// 注意:由于我们使用了估算的 limit,总数是 filtered.Count 而不是数据库中的实际总数
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
using TakeoutSaaS.Application.App.Tenants.Targeting;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 未读公告查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetUnreadAnnouncementsQueryHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IAdminAuthService? adminAuthService = null,
|
||||
IMiniAuthService? miniAuthService = null)
|
||||
: IRequestHandler<GetUnreadAnnouncementsQuery, PagedResult<TenantAnnouncementDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<TenantAnnouncementDto>> Handle(GetUnreadAnnouncementsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var userId = currentUserAccessor?.UserId ?? 0;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// 1. 查询未读公告(已发布/启用/有效期内)
|
||||
var announcements = await announcementRepository.SearchUnreadAsync(
|
||||
tenantId,
|
||||
userId == 0 ? null : userId,
|
||||
AnnouncementStatus.Published,
|
||||
true,
|
||||
now,
|
||||
cancellationToken);
|
||||
|
||||
announcements = announcements
|
||||
.Where(x => x.ScheduledPublishAt == null || x.ScheduledPublishAt <= now)
|
||||
.ToList();
|
||||
|
||||
// 2. 目标受众过滤
|
||||
var targetContext = await AnnouncementTargetContextFactory.BuildAsync(
|
||||
tenantProvider,
|
||||
currentUserAccessor,
|
||||
adminAuthService,
|
||||
miniAuthService,
|
||||
cancellationToken);
|
||||
|
||||
var filtered = announcements
|
||||
.Where(a => TargetTypeFilter.IsMatch(a, targetContext))
|
||||
.ToList();
|
||||
|
||||
// 3. 排序与分页
|
||||
var ordered = filtered
|
||||
.OrderByDescending(x => x.Priority)
|
||||
.ThenByDescending(x => x.EffectiveFrom)
|
||||
.ToList();
|
||||
|
||||
var page = request.Page <= 0 ? 1 : request.Page;
|
||||
var size = request.PageSize <= 0 ? 20 : request.PageSize;
|
||||
var pageItems = ordered
|
||||
.Skip((page - 1) * size)
|
||||
.Take(size)
|
||||
.ToList();
|
||||
|
||||
var items = pageItems
|
||||
.Select(x => x.ToDto(false, null))
|
||||
.ToList();
|
||||
|
||||
return new PagedResult<TenantAnnouncementDto>(items, page, size, ordered.Count);
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 伪装登录租户处理器(平台超级管理员使用)。
|
||||
/// </summary>
|
||||
public sealed class ImpersonateTenantCommandHandler(
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IJwtTokenService jwtTokenService)
|
||||
: IRequestHandler<ImpersonateTenantCommand, TokenResponse>
|
||||
{
|
||||
private const long PlatformRootTenantId = 1000000000001;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TokenResponse> Handle(ImpersonateTenantCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验仅允许平台超级管理员执行
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (currentTenantId != PlatformRootTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅平台超级管理员可执行伪装登录");
|
||||
}
|
||||
|
||||
// 2. 读取操作者信息(在平台租户上下文内)
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? $"user:{currentUserAccessor.UserId}"
|
||||
: operatorProfile.DisplayName;
|
||||
|
||||
// 2. 校验租户存在且存在主管理员
|
||||
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
|
||||
|
||||
// 2.1 若缺少主管理员则自动回填(兼容历史数据)
|
||||
if (!tenant.PrimaryOwnerUserId.HasValue || tenant.PrimaryOwnerUserId.Value == 0)
|
||||
{
|
||||
var originalContextForFix = tenantContextAccessor.Current;
|
||||
tenantContextAccessor.Current = new TenantContext(tenant.Id, tenant.Code, "admin:impersonate:fix-owner");
|
||||
try
|
||||
{
|
||||
var users = await identityUserRepository.SearchAsync(tenant.Id, keyword: null, cancellationToken);
|
||||
var ownerCandidate = users.OrderBy(x => x.CreatedAt).FirstOrDefault();
|
||||
if (ownerCandidate == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "该租户未配置主管理员账号,且未找到可用管理员账号");
|
||||
}
|
||||
|
||||
tenant.PrimaryOwnerUserId = ownerCandidate.Id;
|
||||
await tenantRepository.UpdateTenantAsync(tenant, cancellationToken);
|
||||
await tenantRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContextAccessor.Current = originalContextForFix;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 进入目标租户上下文以读取租户内用户(避免多租户查询过滤导致找不到用户)
|
||||
var originalTenantContext = tenantContextAccessor.Current;
|
||||
tenantContextAccessor.Current = new TenantContext(tenant.Id, null, "admin:impersonate");
|
||||
try
|
||||
{
|
||||
// 4. 为租户主管理员签发令牌
|
||||
var targetProfile = await adminAuthService.GetProfileAsync(tenant.PrimaryOwnerUserId.Value, cancellationToken);
|
||||
var token = await jwtTokenService.CreateTokensAsync(targetProfile, false, cancellationToken);
|
||||
|
||||
// 5. 恢复租户上下文后写入审计日志
|
||||
tenantContextAccessor.Current = originalTenantContext;
|
||||
var auditLog = new TenantAuditLog
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
Action = TenantAuditAction.ImpersonatedLogin,
|
||||
Title = "伪装登录",
|
||||
Description = $"操作者:{operatorName},目标账号:{targetProfile.Account}",
|
||||
OperatorId = currentUserAccessor.UserId,
|
||||
OperatorName = operatorName,
|
||||
PreviousStatus = tenant.Status,
|
||||
CurrentStatus = tenant.Status
|
||||
};
|
||||
await tenantRepository.AddAuditLogAsync(auditLog, cancellationToken);
|
||||
await tenantRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 6. 返回令牌
|
||||
return token;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 7. 确保恢复租户上下文
|
||||
tenantContextAccessor.Current = originalTenantContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Targeting;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 标记公告已读处理器。
|
||||
/// </summary>
|
||||
public sealed class MarkAnnouncementAsReadCommandHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ITenantAnnouncementReadRepository readRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IAdminAuthService? adminAuthService = null,
|
||||
IMiniAuthService? miniAuthService = null)
|
||||
: IRequestHandler<MarkAnnouncementAsReadCommand, TenantAnnouncementDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 标记公告已读。
|
||||
/// </summary>
|
||||
/// <param name="request">标记命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>公告 DTO 或 null。</returns>
|
||||
public async Task<TenantAnnouncementDto?> Handle(MarkAnnouncementAsReadCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
|
||||
// 1. 查询公告(含平台公告)
|
||||
var announcement = await announcementRepository.FindByIdInScopeAsync(tenantId, request.AnnouncementId, cancellationToken);
|
||||
if (announcement == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 仅允许已发布且在有效期内的公告标记已读
|
||||
var now = DateTime.UtcNow;
|
||||
if (announcement.Status != AnnouncementStatus.Published)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (announcement.EffectiveFrom > now || (announcement.EffectiveTo.HasValue && announcement.EffectiveTo.Value < now))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (announcement.ScheduledPublishAt.HasValue && announcement.ScheduledPublishAt.Value > now)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 目标受众过滤
|
||||
var targetContext = await AnnouncementTargetContextFactory.BuildAsync(
|
||||
tenantProvider,
|
||||
currentUserAccessor,
|
||||
adminAuthService,
|
||||
miniAuthService,
|
||||
cancellationToken);
|
||||
|
||||
if (!TargetTypeFilter.IsMatch(announcement, targetContext))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4. 确定用户标识
|
||||
var userId = targetContext.UserId == 0 ? (long?)null : targetContext.UserId;
|
||||
var existing = await readRepository.FindAsync(tenantId, announcement.Id, userId, cancellationToken);
|
||||
|
||||
if (existing == null && userId.HasValue)
|
||||
{
|
||||
existing = await readRepository.FindAsync(tenantId, announcement.Id, null, cancellationToken);
|
||||
}
|
||||
|
||||
// 5. 如未读则写入已读记录
|
||||
if (existing == null)
|
||||
{
|
||||
var record = new TenantAnnouncementRead
|
||||
{
|
||||
TenantId = tenantId,
|
||||
AnnouncementId = announcement.Id,
|
||||
UserId = userId,
|
||||
ReadAt = now
|
||||
};
|
||||
|
||||
await readRepository.AddAsync(record, cancellationToken);
|
||||
await readRepository.SaveChangesAsync(cancellationToken);
|
||||
existing = record;
|
||||
}
|
||||
|
||||
return announcement.ToDto(true, existing.ReadAt);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ using TakeoutSaaS.Domain.Tenants.Events;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
@@ -16,7 +15,6 @@ namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
/// </summary>
|
||||
public sealed class PublishAnnouncementCommandHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
IEventPublisher eventPublisher)
|
||||
: IRequestHandler<PublishAnnouncementCommand, TenantAnnouncementDto?>
|
||||
{
|
||||
@@ -29,7 +27,7 @@ public sealed class PublishAnnouncementCommandHandler(
|
||||
}
|
||||
|
||||
// 1. 查询公告
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var tenantId = request.TenantId;
|
||||
var announcement = await announcementRepository.FindByIdAsync(tenantId, request.AnnouncementId, cancellationToken);
|
||||
if (announcement == null)
|
||||
{
|
||||
|
||||
@@ -7,7 +7,6 @@ using TakeoutSaaS.Domain.Tenants.Events;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
@@ -16,7 +15,6 @@ namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
/// </summary>
|
||||
public sealed class RevokeAnnouncementCommandHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
IEventPublisher eventPublisher)
|
||||
: IRequestHandler<RevokeAnnouncementCommand, TenantAnnouncementDto?>
|
||||
{
|
||||
@@ -29,7 +27,7 @@ public sealed class RevokeAnnouncementCommandHandler(
|
||||
}
|
||||
|
||||
// 1. 查询公告
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var tenantId = request.TenantId;
|
||||
var announcement = await announcementRepository.FindByIdAsync(tenantId, request.AnnouncementId, cancellationToken);
|
||||
if (announcement == null)
|
||||
{
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 自助注册租户处理器。
|
||||
/// </summary>
|
||||
public sealed class SelfRegisterTenantCommandHandler(
|
||||
ITenantRepository tenantRepository,
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IPasswordHasher<IdentityUser> passwordHasher,
|
||||
IIdGenerator idGenerator,
|
||||
IMediator mediator,
|
||||
ITenantContextAccessor tenantContextAccessor)
|
||||
: IRequestHandler<SelfRegisterTenantCommand, SelfRegisterResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<SelfRegisterResultDto> Handle(SelfRegisterTenantCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 唯一性校验
|
||||
var normalizedAccount = request.AdminAccount.Trim();
|
||||
if (await identityUserRepository.ExistsByAccountAsync(normalizedAccount, cancellationToken))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, $"账号 {normalizedAccount} 已存在");
|
||||
}
|
||||
|
||||
// 1.2 校验手机号唯一性
|
||||
var normalizedPhone = request.AdminPhone.Trim();
|
||||
if (await tenantRepository.ExistsByContactPhoneAsync(normalizedPhone, cancellationToken))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, $"手机号 {normalizedPhone} 已注册");
|
||||
}
|
||||
|
||||
// 2. 生成租户标识与编码
|
||||
var tenantId = idGenerator.NextId();
|
||||
var tenantCode = $"t{tenantId}";
|
||||
|
||||
// 3. 构建租户(无订阅,待审核)
|
||||
var tenant = new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Code = tenantCode,
|
||||
Name = normalizedAccount,
|
||||
ShortName = string.IsNullOrWhiteSpace(request.AdminDisplayName) ? normalizedAccount : request.AdminDisplayName!.Trim(),
|
||||
ContactName = string.IsNullOrWhiteSpace(request.AdminDisplayName) ? normalizedAccount : request.AdminDisplayName!.Trim(),
|
||||
ContactPhone = normalizedPhone,
|
||||
ContactEmail = request.AdminEmail,
|
||||
Status = TenantStatus.PendingReview,
|
||||
EffectiveFrom = null,
|
||||
EffectiveTo = null
|
||||
};
|
||||
|
||||
// 4. 写入审计日志
|
||||
var auditLog = new TenantAuditLog
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
Action = TenantAuditAction.RegistrationSubmitted,
|
||||
Title = "自助注册",
|
||||
Description = "自助注册提交,等待补充资料与审核"
|
||||
};
|
||||
|
||||
// 5. 持久化租户与审计
|
||||
await tenantRepository.AddTenantAsync(tenant, cancellationToken);
|
||||
await tenantRepository.AddAuditLogAsync(auditLog, cancellationToken);
|
||||
await tenantRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 6. 临时切换租户上下文,保证身份与权限写入正确
|
||||
var previousContext = tenantContextAccessor.Current;
|
||||
tenantContextAccessor.Current = new TenantContext(tenant.Id, tenant.Code, "self-register");
|
||||
try
|
||||
{
|
||||
// 7. 使用用户自设密码创建管理员
|
||||
var adminUser = new IdentityUser
|
||||
{
|
||||
Portal = PortalType.Tenant,
|
||||
TenantId = tenant.Id,
|
||||
Account = normalizedAccount,
|
||||
DisplayName = string.IsNullOrWhiteSpace(request.AdminDisplayName) ? normalizedAccount : request.AdminDisplayName!.Trim(),
|
||||
PasswordHash = string.Empty,
|
||||
Phone = normalizedPhone,
|
||||
Email = string.IsNullOrWhiteSpace(request.AdminEmail) ? null : request.AdminEmail.Trim()
|
||||
};
|
||||
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, request.AdminPassword);
|
||||
await identityUserRepository.AddAsync(adminUser, cancellationToken);
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 7.1 回填主管理员标识,确保后续伪装登录/重置管理员等能力可用
|
||||
tenant.PrimaryOwnerUserId = adminUser.Id;
|
||||
await tenantRepository.UpdateTenantAsync(tenant, cancellationToken);
|
||||
await tenantRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 8. 初始化租户管理员角色模板
|
||||
await mediator.Send(new InitializeRoleTemplatesCommand
|
||||
{
|
||||
TemplateCodes = new[] { "tenant-admin" }
|
||||
}, cancellationToken);
|
||||
|
||||
// 9. 绑定租户管理员角色
|
||||
var tenantAdminRole = await roleRepository.FindByCodeAsync(PortalType.Tenant, tenant.Id, "tenant-admin", cancellationToken);
|
||||
if (tenantAdminRole != null)
|
||||
{
|
||||
await mediator.Send(new AssignUserRolesCommand
|
||||
{
|
||||
UserId = adminUser.Id,
|
||||
RoleIds = new[] { tenantAdminRole.Id }
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
// 10. 返回注册结果
|
||||
return new SelfRegisterResultDto
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
Code = tenant.Code,
|
||||
Status = tenant.Status,
|
||||
VerificationStatus = TenantVerificationStatus.Draft,
|
||||
EffectiveFrom = tenant.EffectiveFrom,
|
||||
EffectiveTo = tenant.EffectiveTo,
|
||||
AdminAccount = adminUser.Account
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 11. 恢复上下文
|
||||
tenantContextAccessor.Current = previousContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ namespace TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
public sealed record GetAnnouncementByIdQuery : IRequest<TenantAnnouncementDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法,兼容旧调用,实际以当前租户为准)。
|
||||
/// 租户 ID(0 表示平台公告)。
|
||||
/// </summary>
|
||||
public long TenantId { get; init; }
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 公共场景分页查询启用套餐。
|
||||
/// </summary>
|
||||
public sealed record GetPublicTenantPackagesQuery : IRequest<PagedResult<TenantPackageDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 租户入住进度查询。
|
||||
/// </summary>
|
||||
public sealed record GetTenantProgressQuery : IRequest<TenantProgressDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public long TenantId { get; init; }
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询未读公告。
|
||||
/// </summary>
|
||||
public sealed record GetUnreadAnnouncementsQuery : IRequest<PagedResult<TenantAnnouncementDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码(从 1 开始)。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Targeting;
|
||||
|
||||
/// <summary>
|
||||
/// 目标受众上下文构建器。
|
||||
/// </summary>
|
||||
internal static class AnnouncementTargetContextFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// 构建当前用户的目标上下文。
|
||||
/// </summary>
|
||||
public static async Task<AnnouncementTargetContext> BuildAsync(
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor,
|
||||
IAdminAuthService? adminAuthService,
|
||||
IMiniAuthService? miniAuthService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var userId = currentUserAccessor?.UserId ?? 0;
|
||||
long? merchantId = null;
|
||||
IReadOnlyCollection<string> roles = Array.Empty<string>();
|
||||
IReadOnlyCollection<string> permissions = Array.Empty<string>();
|
||||
|
||||
if (userId != 0)
|
||||
{
|
||||
CurrentUserProfile? profile = null;
|
||||
if (adminAuthService != null)
|
||||
{
|
||||
profile = await adminAuthService.GetProfileAsync(userId, cancellationToken);
|
||||
}
|
||||
else if (miniAuthService != null)
|
||||
{
|
||||
profile = await miniAuthService.GetProfileAsync(userId, cancellationToken);
|
||||
}
|
||||
|
||||
if (profile != null)
|
||||
{
|
||||
merchantId = profile.MerchantId;
|
||||
roles = profile.Roles ?? Array.Empty<string>();
|
||||
permissions = profile.Permissions ?? Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
return new AnnouncementTargetContext
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
MerchantId = merchantId,
|
||||
Roles = roles,
|
||||
Permissions = permissions
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Targeting;
|
||||
|
||||
/// <summary>
|
||||
/// 目标受众过滤器。
|
||||
/// </summary>
|
||||
public static class TargetTypeFilter
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 判断公告是否匹配当前用户上下文。
|
||||
/// </summary>
|
||||
/// <param name="announcement">公告实体。</param>
|
||||
/// <param name="context">目标上下文。</param>
|
||||
/// <returns>是否匹配。</returns>
|
||||
public static bool IsMatch(TenantAnnouncement announcement, AnnouncementTargetContext context)
|
||||
{
|
||||
if (announcement == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var targetType = announcement.TargetType?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(targetType))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalized = targetType.ToUpperInvariant();
|
||||
var parsed = TryParseParameters(announcement.TargetParameters, out var payload);
|
||||
|
||||
return normalized switch
|
||||
{
|
||||
"ALL" => announcement.TenantId == 0
|
||||
? ApplyPayloadConstraints(payload, parsed, context, allowEmpty: true)
|
||||
: announcement.TenantId == context.TenantId
|
||||
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: true),
|
||||
"ALL_TENANTS" => ApplyPayloadConstraints(payload, parsed, context, allowEmpty: true),
|
||||
"TENANT_ALL" => announcement.TenantId == context.TenantId
|
||||
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: true),
|
||||
"SPECIFIC_TENANTS" => RequireTenantMatch(payload, parsed, context)
|
||||
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: false),
|
||||
"USERS" or "SPECIFIC_USERS" or "USER_IDS" => RequireUserMatch(payload, parsed, context)
|
||||
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: false),
|
||||
"ROLES" or "ROLE" => RequireRoleMatch(payload, parsed, context)
|
||||
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: false),
|
||||
"PERMISSIONS" or "PERMISSION" => RequirePermissionMatch(payload, parsed, context)
|
||||
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: false),
|
||||
"MERCHANTS" or "MERCHANT_IDS" => RequireMerchantMatch(payload, parsed, context)
|
||||
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: false),
|
||||
_ => ApplyPayloadConstraints(payload, parsed, context, allowEmpty: false)
|
||||
};
|
||||
}
|
||||
|
||||
private static bool RequireTenantMatch(TargetParametersPayload payload, bool parsed, AnnouncementTargetContext context)
|
||||
=> parsed && payload.TenantIds is { Length: > 0 } && payload.TenantIds.Contains(context.TenantId);
|
||||
|
||||
private static bool RequireUserMatch(TargetParametersPayload payload, bool parsed, AnnouncementTargetContext context)
|
||||
=> parsed && payload.UserIds is { Length: > 0 } && context.UserId != 0 && payload.UserIds.Contains(context.UserId);
|
||||
|
||||
private static bool RequireMerchantMatch(TargetParametersPayload payload, bool parsed, AnnouncementTargetContext context)
|
||||
=> parsed && payload.MerchantIds is { Length: > 0 } && context.MerchantId.HasValue && payload.MerchantIds.Contains(context.MerchantId.Value);
|
||||
|
||||
private static bool RequireRoleMatch(TargetParametersPayload payload, bool parsed, AnnouncementTargetContext context)
|
||||
=> parsed && payload.Roles is { Length: > 0 } && Intersects(payload.Roles, context.Roles);
|
||||
|
||||
private static bool RequirePermissionMatch(TargetParametersPayload payload, bool parsed, AnnouncementTargetContext context)
|
||||
=> parsed && payload.Permissions is { Length: > 0 } && Intersects(payload.Permissions, context.Permissions);
|
||||
|
||||
private static bool ApplyPayloadConstraints(
|
||||
TargetParametersPayload payload,
|
||||
bool parsed,
|
||||
AnnouncementTargetContext context,
|
||||
bool allowEmpty)
|
||||
{
|
||||
if (!parsed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!payload.HasConstraints)
|
||||
{
|
||||
return allowEmpty;
|
||||
}
|
||||
|
||||
if (payload.TenantIds is { Length: > 0 } && !payload.TenantIds.Contains(context.TenantId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.UserIds is { Length: > 0 })
|
||||
{
|
||||
if (context.UserId == 0 || !payload.UserIds.Contains(context.UserId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.MerchantIds is { Length: > 0 })
|
||||
{
|
||||
if (!context.MerchantId.HasValue || !payload.MerchantIds.Contains(context.MerchantId.Value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.Roles is { Length: > 0 } && !Intersects(payload.Roles, context.Roles))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.Permissions is { Length: > 0 } && !Intersects(payload.Permissions, context.Permissions))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.Departments is { Length: > 0 } && !Intersects(payload.Departments, context.Departments))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseParameters(string? json, out TargetParametersPayload payload)
|
||||
{
|
||||
payload = new TargetParametersPayload();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
payload = JsonSerializer.Deserialize<TargetParametersPayload>(json, Options) ?? new TargetParametersPayload();
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Intersects(IEnumerable<string> left, IEnumerable<string> right)
|
||||
{
|
||||
var set = new HashSet<string>(right ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var value in left ?? Array.Empty<string>())
|
||||
{
|
||||
if (set.Contains(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private sealed class TargetParametersPayload
|
||||
{
|
||||
public long[]? TenantIds { get; init; }
|
||||
public long[]? UserIds { get; init; }
|
||||
public long[]? MerchantIds { get; init; }
|
||||
public string[]? Roles { get; init; }
|
||||
public string[]? Permissions { get; init; }
|
||||
public string[]? Departments { get; init; }
|
||||
|
||||
public bool HasConstraints
|
||||
=> (TenantIds?.Length ?? 0) > 0
|
||||
|| (UserIds?.Length ?? 0) > 0
|
||||
|| (MerchantIds?.Length ?? 0) > 0
|
||||
|| (Roles?.Length ?? 0) > 0
|
||||
|| (Permissions?.Length ?? 0) > 0
|
||||
|| (Departments?.Length ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 目标受众上下文。
|
||||
/// </summary>
|
||||
public sealed record AnnouncementTargetContext
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
public long UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商户 ID(可选)。
|
||||
/// </summary>
|
||||
public long? MerchantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 角色集合。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> Roles { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 权限集合。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> Permissions { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 部门集合(可选)。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> Departments { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 自助注册租户命令验证器。
|
||||
/// </summary>
|
||||
public sealed class SelfRegisterTenantCommandValidator : AbstractValidator<SelfRegisterTenantCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public SelfRegisterTenantCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.AdminAccount)
|
||||
.NotEmpty()
|
||||
.MaximumLength(64)
|
||||
.Matches("^[A-Za-z0-9]+$")
|
||||
.WithMessage("登录账号仅允许大小写字母和数字");
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,11 @@ namespace TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
/// </summary>
|
||||
public sealed class CreateDictionaryGroupRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(仅当 Scope=Business 时必填)。
|
||||
/// </summary>
|
||||
public long? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分组编码。
|
||||
/// </summary>
|
||||
|
||||
@@ -7,6 +7,11 @@ namespace TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
/// </summary>
|
||||
public sealed class DictionaryBatchQueryRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(为空或 0 表示仅读取系统字典)。
|
||||
/// </summary>
|
||||
public long? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分组编码集合。
|
||||
/// </summary>
|
||||
|
||||
@@ -7,6 +7,11 @@ namespace TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
/// </summary>
|
||||
public sealed class DictionaryGroupQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(仅当 Scope=Business 时需要;Scope=System 时忽略)。
|
||||
/// </summary>
|
||||
public long? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 作用域过滤。
|
||||
/// </summary>
|
||||
|
||||
@@ -9,7 +9,6 @@ using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
@@ -19,8 +18,6 @@ namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
public sealed class DictionaryAppService(
|
||||
IDictionaryRepository repository,
|
||||
IDictionaryCache cache,
|
||||
ITenantProvider tenantProvider,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILogger<DictionaryAppService> logger) : IDictionaryAppService
|
||||
{
|
||||
/// <summary>
|
||||
@@ -33,7 +30,7 @@ public sealed class DictionaryAppService(
|
||||
{
|
||||
// 1. 规范化编码并确定租户
|
||||
var normalizedCode = NormalizeCode(request.Code);
|
||||
var targetTenant = ResolveTargetTenant(request.Scope);
|
||||
var targetTenant = ResolveTargetTenant(request.Scope, request.TenantId);
|
||||
|
||||
// 2. 校验编码唯一
|
||||
var existing = await repository.FindGroupByCodeAsync(normalizedCode, cancellationToken);
|
||||
@@ -74,7 +71,6 @@ public sealed class DictionaryAppService(
|
||||
{
|
||||
// 1. 读取分组并校验权限
|
||||
var group = await RequireGroupAsync(groupId, cancellationToken);
|
||||
EnsureScopePermission(group.Scope);
|
||||
|
||||
if (request.RowVersion == null || request.RowVersion.Length == 0)
|
||||
{
|
||||
@@ -116,7 +112,6 @@ public sealed class DictionaryAppService(
|
||||
{
|
||||
// 1. 读取分组并校验权限
|
||||
var group = await RequireGroupAsync(groupId, cancellationToken);
|
||||
EnsureScopePermission(group.Scope);
|
||||
|
||||
// 2. 删除并失效缓存
|
||||
await repository.RemoveGroupAsync(group, cancellationToken);
|
||||
@@ -134,9 +129,8 @@ public sealed class DictionaryAppService(
|
||||
public async Task<IReadOnlyList<DictionaryGroupDto>> SearchGroupsAsync(DictionaryGroupQuery request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 确定查询范围并校验权限
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var tenantId = request.TenantId ?? 0;
|
||||
var scope = ResolveScopeForQuery(request.Scope, tenantId);
|
||||
EnsureScopePermission(scope);
|
||||
|
||||
// 2. 查询分组及可选项
|
||||
var groups = await repository.SearchGroupsAsync(scope, cancellationToken);
|
||||
@@ -169,7 +163,6 @@ public sealed class DictionaryAppService(
|
||||
{
|
||||
// 1. 校验分组与权限
|
||||
var group = await RequireGroupAsync(request.GroupId, cancellationToken);
|
||||
EnsureScopePermission(group.Scope);
|
||||
|
||||
// 2. 构建字典项
|
||||
var item = new DictionaryItem
|
||||
@@ -206,7 +199,6 @@ public sealed class DictionaryAppService(
|
||||
// 1. 读取字典项与分组并校验权限
|
||||
var item = await RequireItemAsync(itemId, cancellationToken);
|
||||
var group = await RequireGroupAsync(item.GroupId, cancellationToken);
|
||||
EnsureScopePermission(group.Scope);
|
||||
|
||||
if (request.RowVersion == null || request.RowVersion.Length == 0)
|
||||
{
|
||||
@@ -251,7 +243,6 @@ public sealed class DictionaryAppService(
|
||||
// 1. 读取字典项与分组并校验权限
|
||||
var item = await RequireItemAsync(itemId, cancellationToken);
|
||||
var group = await RequireGroupAsync(item.GroupId, cancellationToken);
|
||||
EnsureScopePermission(group.Scope);
|
||||
|
||||
// 2. 删除并失效缓存
|
||||
await repository.RemoveItemAsync(item, cancellationToken);
|
||||
@@ -281,7 +272,7 @@ public sealed class DictionaryAppService(
|
||||
}
|
||||
|
||||
// 2. 按租户合并系统与业务字典
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var tenantId = request.TenantId ?? 0;
|
||||
var result = new Dictionary<string, IReadOnlyList<DictionaryItemDto>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var code in normalizedCodes)
|
||||
@@ -324,21 +315,19 @@ public sealed class DictionaryAppService(
|
||||
return item;
|
||||
}
|
||||
|
||||
private long ResolveTargetTenant(DictionaryScope scope)
|
||||
private static long ResolveTargetTenant(DictionaryScope scope, long? tenantId)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (scope == DictionaryScope.System)
|
||||
{
|
||||
EnsurePlatformTenant(tenantId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (tenantId == 0)
|
||||
if (!tenantId.HasValue || tenantId.Value <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "业务参数需指定租户");
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "业务参数需指定租户");
|
||||
}
|
||||
|
||||
return tenantId;
|
||||
return tenantId.Value;
|
||||
}
|
||||
|
||||
private static string NormalizeCode(string code) => code.Trim().ToLowerInvariant();
|
||||
@@ -353,23 +342,6 @@ public sealed class DictionaryAppService(
|
||||
return tenantId == 0 ? DictionaryScope.System : DictionaryScope.Business;
|
||||
}
|
||||
|
||||
private void EnsureScopePermission(DictionaryScope scope)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (scope == DictionaryScope.System && tenantId != 0 && !DictionaryAccessHelper.IsPlatformAdmin(httpContextAccessor))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典");
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsurePlatformTenant(long tenantId)
|
||||
{
|
||||
if (tenantId != 0 && !DictionaryAccessHelper.IsPlatformAdmin(httpContextAccessor))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InvalidateCacheAsync(DictionaryGroup group, CancellationToken cancellationToken)
|
||||
{
|
||||
await cache.RemoveAsync(group.TenantId, group.Code, cancellationToken);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
@@ -10,7 +9,6 @@ using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
@@ -21,8 +19,6 @@ public sealed class DictionaryCommandService(
|
||||
IDictionaryGroupRepository groupRepository,
|
||||
IDictionaryItemRepository itemRepository,
|
||||
IDictionaryHybridCache cache,
|
||||
ITenantProvider tenantProvider,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILogger<DictionaryCommandService> logger)
|
||||
{
|
||||
/// <summary>
|
||||
@@ -30,7 +26,7 @@ public sealed class DictionaryCommandService(
|
||||
/// </summary>
|
||||
public async Task<DictionaryGroupDto> CreateGroupAsync(CreateDictionaryGroupRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var targetTenantId = ResolveTargetTenant(request.Scope);
|
||||
var targetTenantId = ResolveTargetTenant(request.Scope, request.TenantId);
|
||||
var code = new DictionaryCode(request.Code);
|
||||
|
||||
var existing = await groupRepository.GetByCodeAsync(targetTenantId, code, cancellationToken);
|
||||
@@ -68,7 +64,6 @@ public sealed class DictionaryCommandService(
|
||||
public async Task<DictionaryGroupDto> UpdateGroupAsync(long groupId, UpdateDictionaryGroupRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var group = await RequireGroupAsync(groupId, cancellationToken);
|
||||
EnsureGroupAccess(group);
|
||||
|
||||
EnsureRowVersion(request.RowVersion, group.RowVersion, "字典分组");
|
||||
|
||||
@@ -103,8 +98,6 @@ public sealed class DictionaryCommandService(
|
||||
return false;
|
||||
}
|
||||
|
||||
EnsureGroupAccess(group);
|
||||
|
||||
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, cancellationToken);
|
||||
foreach (var item in items)
|
||||
{
|
||||
@@ -125,7 +118,6 @@ public sealed class DictionaryCommandService(
|
||||
public async Task<DictionaryItemDto> CreateItemAsync(CreateDictionaryItemRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var group = await RequireGroupAsync(request.GroupId, cancellationToken);
|
||||
EnsureGroupAccess(group);
|
||||
|
||||
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, cancellationToken);
|
||||
var normalizedKey = request.Key.Trim();
|
||||
@@ -168,7 +160,6 @@ public sealed class DictionaryCommandService(
|
||||
{
|
||||
var item = await RequireItemAsync(itemId, cancellationToken);
|
||||
var group = await RequireGroupAsync(item.GroupId, cancellationToken);
|
||||
EnsureGroupAccess(group);
|
||||
|
||||
EnsureRowVersion(request.RowVersion, item.RowVersion, "字典项");
|
||||
|
||||
@@ -216,7 +207,6 @@ public sealed class DictionaryCommandService(
|
||||
}
|
||||
|
||||
var group = await RequireGroupAsync(item.GroupId, cancellationToken);
|
||||
EnsureGroupAccess(group);
|
||||
|
||||
await itemRepository.RemoveAsync(item, cancellationToken);
|
||||
await groupRepository.SaveChangesAsync(cancellationToken);
|
||||
@@ -226,39 +216,19 @@ public sealed class DictionaryCommandService(
|
||||
return true;
|
||||
}
|
||||
|
||||
private long ResolveTargetTenant(DictionaryScope scope)
|
||||
private static long ResolveTargetTenant(DictionaryScope scope, long? tenantId)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (scope == DictionaryScope.System)
|
||||
{
|
||||
if (tenantId != 0 && !DictionaryAccessHelper.IsPlatformAdmin(httpContextAccessor))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可创建系统字典");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (tenantId == 0)
|
||||
if (!tenantId.HasValue || tenantId.Value <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "业务字典必须在租户上下文中创建");
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "业务字典必须指定 TenantId");
|
||||
}
|
||||
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
private void EnsureGroupAccess(DictionaryGroup group)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (group.Scope == DictionaryScope.System && tenantId != 0 && !DictionaryAccessHelper.IsPlatformAdmin(httpContextAccessor))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典");
|
||||
}
|
||||
|
||||
if (group.Scope == DictionaryScope.Business && tenantId != group.TenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "无权操作其他租户字典");
|
||||
}
|
||||
return tenantId.Value;
|
||||
}
|
||||
|
||||
private static void EnsureRowVersion(byte[]? requestVersion, byte[] entityVersion, string resourceName)
|
||||
|
||||
@@ -12,9 +12,7 @@ using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
@@ -28,9 +26,7 @@ public sealed class DictionaryImportExportService(
|
||||
IDictionaryItemRepository itemRepository,
|
||||
IDictionaryImportLogRepository importLogRepository,
|
||||
IDictionaryHybridCache cache,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUser,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILogger<DictionaryImportExportService> logger)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
@@ -41,7 +37,6 @@ public sealed class DictionaryImportExportService(
|
||||
public async Task ExportToCsvAsync(long groupId, Stream output, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var group = await RequireGroupAsync(groupId, cancellationToken);
|
||||
EnsureGroupReadable(group);
|
||||
|
||||
var items = await ResolveExportItemsAsync(group, cancellationToken);
|
||||
await WriteCsvAsync(group, items, output, cancellationToken);
|
||||
@@ -53,7 +48,6 @@ public sealed class DictionaryImportExportService(
|
||||
public async Task ExportToJsonAsync(long groupId, Stream output, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var group = await RequireGroupAsync(groupId, cancellationToken);
|
||||
EnsureGroupReadable(group);
|
||||
|
||||
var items = await ResolveExportItemsAsync(group, cancellationToken);
|
||||
var payload = items.Select(item => new DictionaryExportRow
|
||||
@@ -96,7 +90,6 @@ public sealed class DictionaryImportExportService(
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var group = await RequireGroupAsync(request.GroupId, cancellationToken);
|
||||
EnsureGroupWritable(group);
|
||||
|
||||
var errors = new List<DictionaryImportResultDto.ImportError>();
|
||||
var validRows = new List<NormalizedRow>(rows.Count);
|
||||
@@ -210,14 +203,6 @@ public sealed class DictionaryImportExportService(
|
||||
|
||||
private async Task<IReadOnlyList<DictionaryItemDto>> ResolveExportItemsAsync(DictionaryGroup group, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
|
||||
if (group.Scope == DictionaryScope.System && tenantId != 0)
|
||||
{
|
||||
var mergedItems = await itemRepository.GetMergedItemsAsync(tenantId, group.Id, includeOverrides: true, cancellationToken);
|
||||
return mergedItems.Select(DictionaryMapper.ToItemDto).ToList();
|
||||
}
|
||||
|
||||
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, cancellationToken);
|
||||
return items.Select(DictionaryMapper.ToItemDto).ToList();
|
||||
}
|
||||
@@ -423,34 +408,6 @@ public sealed class DictionaryImportExportService(
|
||||
return group;
|
||||
}
|
||||
|
||||
private void EnsureGroupAccess(DictionaryGroup group)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (group.Scope == DictionaryScope.System && tenantId != 0 && !DictionaryAccessHelper.IsPlatformAdmin(httpContextAccessor))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典");
|
||||
}
|
||||
|
||||
if (group.Scope == DictionaryScope.Business && tenantId != group.TenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "无权操作其他租户字典");
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureGroupReadable(DictionaryGroup group)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (tenantId != 0 && group.Scope == DictionaryScope.Business && group.TenantId != tenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "无权访问其他租户字典");
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureGroupWritable(DictionaryGroup group)
|
||||
{
|
||||
EnsureGroupAccess(group);
|
||||
}
|
||||
|
||||
private static DictionaryImportResultDto.ImportError CreateError(int rowNumber, string field, string message)
|
||||
=> new()
|
||||
{
|
||||
|
||||
@@ -8,7 +8,6 @@ using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
@@ -18,9 +17,7 @@ namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
public sealed class DictionaryQueryService(
|
||||
IDictionaryGroupRepository groupRepository,
|
||||
IDictionaryItemRepository itemRepository,
|
||||
DictionaryMergeService mergeService,
|
||||
IDictionaryHybridCache cache,
|
||||
ITenantProvider tenantProvider)
|
||||
IDictionaryHybridCache cache)
|
||||
{
|
||||
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(30);
|
||||
|
||||
@@ -31,8 +28,21 @@ public sealed class DictionaryQueryService(
|
||||
DictionaryGroupQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
// 1. 解析查询租户与作用域
|
||||
var tenantId = query.TenantId ?? 0;
|
||||
if (query.Scope == DictionaryScope.Business && tenantId <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "Scope=Business 时必须指定 TenantId");
|
||||
}
|
||||
|
||||
// 2. (空行后) 确定作用域与目标租户
|
||||
var scope = query.Scope ?? (tenantId == 0 ? DictionaryScope.System : DictionaryScope.Business);
|
||||
if (scope == DictionaryScope.System)
|
||||
{
|
||||
tenantId = 0;
|
||||
}
|
||||
|
||||
// 3. (空行后) 构建缓存键并加载分页数据
|
||||
var sortDescending = string.Equals(query.SortOrder, "desc", StringComparison.OrdinalIgnoreCase);
|
||||
var targetTenant = scope == DictionaryScope.System ? 0 : tenantId;
|
||||
|
||||
@@ -118,7 +128,6 @@ public sealed class DictionaryQueryService(
|
||||
return null;
|
||||
}
|
||||
|
||||
EnsureGroupReadable(group);
|
||||
return DictionaryMapper.ToGroupDto(group);
|
||||
}
|
||||
|
||||
@@ -139,7 +148,6 @@ public sealed class DictionaryQueryService(
|
||||
throw new BusinessException(ErrorCodes.NotFound, "字典分组不存在");
|
||||
}
|
||||
|
||||
EnsureGroupReadable(group);
|
||||
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, groupId, token);
|
||||
return items
|
||||
.Where(item => item.IsEnabled)
|
||||
@@ -162,7 +170,8 @@ public sealed class DictionaryQueryService(
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "字典编码格式不正确");
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
// 1. 管理端默认读取系统字典(TenantId=0)
|
||||
var tenantId = 0;
|
||||
var normalized = new DictionaryCode(code);
|
||||
var cacheKey = DictionaryCacheKeys.BuildDictionaryKey(tenantId, normalized);
|
||||
|
||||
@@ -177,17 +186,12 @@ public sealed class DictionaryQueryService(
|
||||
return Array.Empty<DictionaryItemDto>();
|
||||
}
|
||||
|
||||
if (tenantId == 0)
|
||||
{
|
||||
var systemItems = await itemRepository.GetByGroupIdAsync(0, systemGroup.Id, token);
|
||||
return systemItems
|
||||
.Where(item => item.IsEnabled)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.Select(DictionaryMapper.ToItemDto)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return await mergeService.MergeItemsAsync(tenantId, systemGroup.Id, token);
|
||||
var systemItems = await itemRepository.GetByGroupIdAsync(0, systemGroup.Id, token);
|
||||
return systemItems
|
||||
.Where(item => item.IsEnabled)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.Select(DictionaryMapper.ToItemDto)
|
||||
.ToList();
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
@@ -227,15 +231,6 @@ public sealed class DictionaryQueryService(
|
||||
return result;
|
||||
}
|
||||
|
||||
private void EnsureGroupReadable(DictionaryGroup group)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (tenantId != 0 && group.Scope == DictionaryScope.Business && group.TenantId != tenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "无权访问其他租户字典");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DictionaryGroupPage
|
||||
{
|
||||
public IReadOnlyList<DictionaryGroupDto> Items { get; init; } = Array.Empty<DictionaryGroupDto>();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Abstractions;
|
||||
|
||||
@@ -28,16 +27,6 @@ public interface IAdminAuthService
|
||||
/// </summary>
|
||||
Task<CurrentUserProfile> GetProfileAsync(long userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户权限概览。
|
||||
/// </summary>
|
||||
Task<UserPermissionDto?> GetUserPermissionsAsync(long userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 搜索用户权限概览列表。
|
||||
/// </summary>
|
||||
Task<PagedResult<UserPermissionDto>> SearchUserPermissionsAsync(string? keyword, int page, int pageSize, string? sortBy, bool sortDescending, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前用户可见菜单树。
|
||||
/// </summary>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 小程序认证服务。
|
||||
/// </summary>
|
||||
public interface IMiniAuthService
|
||||
{
|
||||
Task<TokenResponse> LoginWithWeChatAsync(WeChatLoginRequest request, CancellationToken cancellationToken = default);
|
||||
Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default);
|
||||
Task<CurrentUserProfile> GetProfileAsync(long userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
namespace TakeoutSaaS.Application.Identity.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 微信 code2Session 服务契约。
|
||||
/// </summary>
|
||||
public interface IWeChatAuthService
|
||||
{
|
||||
/// <summary>
|
||||
/// 调用微信接口完成 code2Session 交换。
|
||||
/// </summary>
|
||||
/// <param name="code">临时登录凭证 code。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>会话信息。</returns>
|
||||
Task<WeChatSessionInfo> Code2SessionAsync(string code, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 微信会话信息。
|
||||
/// </summary>
|
||||
public sealed class WeChatSessionInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// OpenId。
|
||||
/// </summary>
|
||||
public string OpenId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// UnionId。
|
||||
/// </summary>
|
||||
public string? UnionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 会话密钥。
|
||||
/// </summary>
|
||||
public string SessionKey { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 为用户分配角色(覆盖式)。
|
||||
/// </summary>
|
||||
public sealed record AssignUserRolesCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
public long UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 角色 ID 集合。
|
||||
/// </summary>
|
||||
public long[] RoleIds { get; init; } = Array.Empty<long>();
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Commands;
|
||||
|
||||
@@ -8,6 +9,16 @@ namespace TakeoutSaaS.Application.Identity.Commands;
|
||||
/// </summary>
|
||||
public sealed record CopyRoleTemplateCommand : IRequest<RoleDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 目标 Portal。
|
||||
/// </summary>
|
||||
public PortalType Portal { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标租户 ID(Portal=Tenant 时必填;Portal=Admin 时必须为空)。
|
||||
/// </summary>
|
||||
public long? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 模板编码。
|
||||
/// </summary>
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 批量为当前租户初始化角色模板。
|
||||
/// </summary>
|
||||
public sealed record InitializeRoleTemplatesCommand : IRequest<IReadOnlyList<RoleDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 需要初始化的模板编码列表(为空则全部)。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string>? TemplateCodes { get; init; }
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 微信小程序登录请求。
|
||||
/// </summary>
|
||||
public sealed class WeChatLoginRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// wx.login 返回的临时 code。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 用户昵称。
|
||||
/// </summary>
|
||||
[MaxLength(64)]
|
||||
public string? Nickname { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 头像地址。
|
||||
/// </summary>
|
||||
[MaxLength(256)]
|
||||
public string? Avatar { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 加密用户数据。
|
||||
/// </summary>
|
||||
public string? EncryptedData { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 加密向量。
|
||||
/// </summary>
|
||||
public string? Iv { get; set; }
|
||||
}
|
||||
@@ -13,16 +13,10 @@ public static class IdentityServiceCollectionExtensions
|
||||
/// 注册身份认证相关应用服务
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合</param>
|
||||
/// <param name="enableMiniSupport">是否注册小程序认证服务</param>
|
||||
public static IServiceCollection AddIdentityApplication(this IServiceCollection services, bool enableMiniSupport = false)
|
||||
public static IServiceCollection AddIdentityApplication(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IAdminAuthService, AdminAuthService>();
|
||||
|
||||
if (enableMiniSupport)
|
||||
{
|
||||
services.AddScoped<IMiniAuthService, MiniAuthService>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 用户角色分配处理器。
|
||||
/// </summary>
|
||||
public sealed class AssignUserRolesCommandHandler(
|
||||
IUserRoleRepository userRoleRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<AssignUserRolesCommand, bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理用户角色分配请求。
|
||||
/// </summary>
|
||||
/// <param name="request">分配命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>执行结果。</returns>
|
||||
public async Task<bool> Handle(AssignUserRolesCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 固定为租户侧用户分配角色
|
||||
var portal = PortalType.Tenant;
|
||||
|
||||
// 2. 获取租户上下文
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
|
||||
// 3. 覆盖式绑定角色
|
||||
await userRoleRepository.ReplaceUserRolesAsync(portal, tenantId, request.UserId, request.RoleIds, cancellationToken);
|
||||
await userRoleRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 4. 返回执行结果
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
@@ -22,7 +21,6 @@ public sealed class BatchIdentityUserOperationCommandHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IIdentityOperationLogPublisher operationLogPublisher)
|
||||
@@ -31,24 +29,17 @@ public sealed class BatchIdentityUserOperationCommandHandler(
|
||||
/// <inheritdoc />
|
||||
public async Task<BatchIdentityUserOperationResult> Handle(BatchIdentityUserOperationCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
// 1. 获取操作者档案(用于操作日志)
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
// 2. (空行后) 校验租户参数
|
||||
if (!request.TenantId.HasValue || request.TenantId.Value <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户批量操作用户");
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId 不能为空");
|
||||
}
|
||||
|
||||
if (isSuperAdmin && !request.TenantId.HasValue)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "批量操作必须指定租户");
|
||||
}
|
||||
|
||||
// 3. 解析用户 ID 列表
|
||||
var tenantId = request.TenantId ?? currentTenantId;
|
||||
// 3. (空行后) 解析用户 ID 列表
|
||||
var tenantId = request.TenantId.Value;
|
||||
var userIds = ParseIds(request.UserIds, "用户");
|
||||
if (userIds.Length == 0)
|
||||
{
|
||||
|
||||
@@ -2,7 +2,8 @@ using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
@@ -10,8 +11,7 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
/// 绑定角色权限处理器。
|
||||
/// </summary>
|
||||
public sealed class BindRolePermissionsCommandHandler(
|
||||
IRolePermissionRepository rolePermissionRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
IRolePermissionRepository rolePermissionRepository)
|
||||
: IRequestHandler<BindRolePermissionsCommand, bool>
|
||||
{
|
||||
/// <summary>
|
||||
@@ -25,10 +25,16 @@ public sealed class BindRolePermissionsCommandHandler(
|
||||
// 1. 固定绑定租户侧角色权限
|
||||
var portal = PortalType.Tenant;
|
||||
|
||||
// 2. 获取租户上下文
|
||||
var tenantId = request.TenantId ?? tenantProvider.GetCurrentTenantId();
|
||||
// 2. (空行后) 校验租户参数
|
||||
if (!request.TenantId.HasValue || request.TenantId.Value <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId 不能为空");
|
||||
}
|
||||
|
||||
// 3. 覆盖式绑定权限
|
||||
// 3. (空行后) 获取租户标识
|
||||
var tenantId = request.TenantId.Value;
|
||||
|
||||
// 4. (空行后) 覆盖式绑定权限
|
||||
var distinctPermissionIds = request.PermissionIds
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
@@ -37,7 +43,7 @@ public sealed class BindRolePermissionsCommandHandler(
|
||||
await rolePermissionRepository.ReplaceRolePermissionsAsync(portal, tenantId, request.RoleId, distinctPermissionIds, cancellationToken);
|
||||
await rolePermissionRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 4. 返回执行结果
|
||||
// 5. 返回执行结果
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
@@ -19,7 +18,6 @@ public sealed class ChangeIdentityUserStatusCommandHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IIdentityOperationLogPublisher operationLogPublisher)
|
||||
@@ -28,30 +26,17 @@ public sealed class ChangeIdentityUserStatusCommandHandler(
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> Handle(ChangeIdentityUserStatusCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
// 1. 获取操作者档案(用于操作日志)
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户修改用户状态");
|
||||
}
|
||||
|
||||
// 3. 查询用户实体
|
||||
// 2. (空行后) 查询用户实体
|
||||
var user = await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. 校验租户管理员保留规则(仅租户侧用户适用)
|
||||
// 3. 校验租户管理员保留规则(仅租户侧用户适用)
|
||||
if (user.Portal == PortalType.Tenant
|
||||
&& request.Status == IdentityUserStatus.Disabled
|
||||
&& user.Status == IdentityUserStatus.Active
|
||||
@@ -60,7 +45,7 @@ public sealed class ChangeIdentityUserStatusCommandHandler(
|
||||
await EnsureNotLastActiveTenantAdminAsync(user.TenantId.Value, user.Id, cancellationToken);
|
||||
}
|
||||
|
||||
// 5. 更新状态
|
||||
// 4. 更新状态
|
||||
var previousStatus = user.Status;
|
||||
switch (request.Status)
|
||||
{
|
||||
@@ -81,7 +66,7 @@ public sealed class ChangeIdentityUserStatusCommandHandler(
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "无效的用户状态");
|
||||
}
|
||||
|
||||
// 6. 构建操作日志消息
|
||||
// 5. 构建操作日志消息
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? operatorProfile.Account
|
||||
: operatorProfile.DisplayName;
|
||||
@@ -108,7 +93,7 @@ public sealed class ChangeIdentityUserStatusCommandHandler(
|
||||
Success = true
|
||||
};
|
||||
|
||||
// 7. 写入 Outbox 并保存变更
|
||||
// 6. 写入 Outbox 并保存变更
|
||||
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ using TakeoutSaaS.Domain.Identity.Enums;
|
||||
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;
|
||||
|
||||
@@ -17,14 +16,24 @@ public sealed class CopyRoleTemplateCommandHandler(
|
||||
IRoleTemplateRepository roleTemplateRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IPermissionRepository permissionRepository,
|
||||
IRolePermissionRepository rolePermissionRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
IRolePermissionRepository rolePermissionRepository)
|
||||
: IRequestHandler<CopyRoleTemplateCommand, RoleDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<RoleDto> Handle(CopyRoleTemplateCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询模板与模板权限
|
||||
// 1. 校验 Portal 与 TenantId 参数
|
||||
if (request.Portal == PortalType.Admin && request.TenantId is not null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "Portal=Admin 时 TenantId 必须为空");
|
||||
}
|
||||
|
||||
if (request.Portal == PortalType.Tenant && (!request.TenantId.HasValue || request.TenantId.Value <= 0))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "Portal=Tenant 时 TenantId 必须大于 0");
|
||||
}
|
||||
|
||||
// 2. 查询模板与模板权限
|
||||
var template = await roleTemplateRepository.FindByCodeAsync(request.TemplateCode, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, $"角色模板 {request.TemplateCode} 不存在");
|
||||
|
||||
@@ -35,16 +44,16 @@ public sealed class CopyRoleTemplateCommandHandler(
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
// 2. 计算角色名称/编码与描述
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
// 3. 计算角色名称/编码与描述
|
||||
var portal = request.Portal;
|
||||
var tenantId = request.TenantId;
|
||||
|
||||
// 3. 固定复制为租户侧角色
|
||||
var portal = PortalType.Tenant;
|
||||
// 4. (空行后) 解析目标角色信息
|
||||
var roleCode = string.IsNullOrWhiteSpace(request.RoleCode) ? template.TemplateCode : request.RoleCode.Trim();
|
||||
var roleName = string.IsNullOrWhiteSpace(request.RoleName) ? template.Name : request.RoleName.Trim();
|
||||
var roleDescription = request.Description ?? template.Description;
|
||||
|
||||
// 4. 准备或更新角色主体(幂等创建)。
|
||||
// 5. 准备或更新角色主体(幂等创建)。
|
||||
var role = await roleRepository.FindByCodeAsync(portal, tenantId, roleCode, cancellationToken);
|
||||
if (role is null)
|
||||
{
|
||||
@@ -73,7 +82,7 @@ public sealed class CopyRoleTemplateCommandHandler(
|
||||
await roleRepository.UpdateAsync(role, cancellationToken);
|
||||
}
|
||||
|
||||
// 5. 确保模板权限全部存在,不存在则按模板定义创建。
|
||||
// 6. 确保模板权限全部存在,不存在则按模板定义创建。
|
||||
var existingPermissions = await permissionRepository.GetByCodesAsync(permissionCodes, cancellationToken);
|
||||
var permissionMap = existingPermissions.ToDictionary(x => x.Code, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -97,7 +106,7 @@ public sealed class CopyRoleTemplateCommandHandler(
|
||||
|
||||
await roleRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 6. 绑定缺失的权限,保留租户自定义的已有授权。
|
||||
// 7. 绑定缺失的权限,保留租户自定义的已有授权。
|
||||
var rolePermissions = await rolePermissionRepository.GetByRoleIdsAsync(portal, tenantId, new[] { role.Id }, cancellationToken);
|
||||
var existingPermissionIds = rolePermissions
|
||||
.Select(x => x.PermissionId)
|
||||
|
||||
@@ -13,7 +13,6 @@ using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
@@ -25,7 +24,6 @@ public sealed class CreateIdentityUserCommandHandler(
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IPasswordHasher<IdentityUser> passwordHasher,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IIdentityOperationLogPublisher operationLogPublisher,
|
||||
@@ -36,19 +34,17 @@ public sealed class CreateIdentityUserCommandHandler(
|
||||
/// <inheritdoc />
|
||||
public async Task<UserDetailDto> Handle(CreateIdentityUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
// 1. 获取操作者档案(用于操作日志)
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
// 2. (空行后) 校验租户参数
|
||||
if (!request.TenantId.HasValue || request.TenantId.Value <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户创建用户");
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId 不能为空");
|
||||
}
|
||||
|
||||
// 3. 规范化输入并准备校验
|
||||
var tenantId = isSuperAdmin ? request.TenantId ?? currentTenantId : currentTenantId;
|
||||
// 3. (空行后) 规范化输入并准备校验
|
||||
var tenantId = request.TenantId.Value;
|
||||
var account = request.Account.Trim();
|
||||
var displayName = request.DisplayName.Trim();
|
||||
var phone = string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim();
|
||||
|
||||
@@ -6,7 +6,6 @@ using TakeoutSaaS.Domain.Identity.Enums;
|
||||
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;
|
||||
|
||||
@@ -14,8 +13,7 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
/// 创建角色处理器。
|
||||
/// </summary>
|
||||
public sealed class CreateRoleCommandHandler(
|
||||
IRoleRepository roleRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
IRoleRepository roleRepository)
|
||||
: IRequestHandler<CreateRoleCommand, RoleDto>
|
||||
{
|
||||
/// <summary>
|
||||
@@ -28,11 +26,17 @@ public sealed class CreateRoleCommandHandler(
|
||||
{
|
||||
// 1. 固定创建租户侧角色
|
||||
var portal = PortalType.Tenant;
|
||||
|
||||
// 2. 获取租户上下文
|
||||
var tenantId = request.TenantId ?? tenantProvider.GetCurrentTenantId();
|
||||
|
||||
// 3. 归一化输入并校验唯一
|
||||
// 2. (空行后) 校验租户参数
|
||||
if (!request.TenantId.HasValue || request.TenantId.Value <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId 不能为空");
|
||||
}
|
||||
|
||||
// 3. (空行后) 获取租户标识
|
||||
var tenantId = request.TenantId.Value;
|
||||
|
||||
// 4. (空行后) 归一化输入并校验唯一
|
||||
var name = request.Name?.Trim() ?? string.Empty;
|
||||
var code = request.Code?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(code))
|
||||
@@ -46,7 +50,7 @@ public sealed class CreateRoleCommandHandler(
|
||||
throw new BusinessException(ErrorCodes.Conflict, "角色编码已存在");
|
||||
}
|
||||
|
||||
// 4. 构建角色实体
|
||||
// 5. 构建角色实体
|
||||
var role = new Role
|
||||
{
|
||||
Portal = portal,
|
||||
@@ -56,11 +60,11 @@ public sealed class CreateRoleCommandHandler(
|
||||
Description = request.Description
|
||||
};
|
||||
|
||||
// 5. 持久化
|
||||
// 6. 持久化
|
||||
await roleRepository.AddAsync(role, cancellationToken);
|
||||
await roleRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 6. 返回 DTO
|
||||
// 7. 返回 DTO
|
||||
return new RoleDto
|
||||
{
|
||||
Id = role.Id,
|
||||
|
||||
@@ -8,7 +8,6 @@ using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
@@ -19,7 +18,6 @@ public sealed class DeleteIdentityUserCommandHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IIdentityOperationLogPublisher operationLogPublisher)
|
||||
@@ -28,36 +26,23 @@ public sealed class DeleteIdentityUserCommandHandler(
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> Handle(DeleteIdentityUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
// 1. 获取操作者档案(用于操作日志)
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户删除用户");
|
||||
}
|
||||
|
||||
// 3. 查询用户实体
|
||||
// 2. (空行后) 查询用户实体
|
||||
var user = await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. 校验租户管理员保留规则(仅租户侧用户适用)
|
||||
// 3. 校验租户管理员保留规则(仅租户侧用户适用)
|
||||
if (user.Portal == PortalType.Tenant && user.Status == IdentityUserStatus.Active && user.TenantId.HasValue)
|
||||
{
|
||||
await EnsureNotLastActiveTenantAdminAsync(user.TenantId.Value, user.Id, cancellationToken);
|
||||
}
|
||||
|
||||
// 5. 构建操作日志消息
|
||||
// 4. 构建操作日志消息
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? operatorProfile.Account
|
||||
: operatorProfile.DisplayName;
|
||||
@@ -78,7 +63,7 @@ public sealed class DeleteIdentityUserCommandHandler(
|
||||
Success = true
|
||||
};
|
||||
|
||||
// 6. 软删除用户并写入 Outbox
|
||||
// 5. 软删除用户并写入 Outbox
|
||||
await identityUserRepository.RemoveAsync(user, cancellationToken);
|
||||
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
@@ -2,7 +2,8 @@ using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
@@ -10,8 +11,7 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
/// 删除角色处理器。
|
||||
/// </summary>
|
||||
public sealed class DeleteRoleCommandHandler(
|
||||
IRoleRepository roleRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
IRoleRepository roleRepository)
|
||||
: IRequestHandler<DeleteRoleCommand, bool>
|
||||
{
|
||||
/// <summary>
|
||||
@@ -25,14 +25,19 @@ public sealed class DeleteRoleCommandHandler(
|
||||
// 1. 固定删除租户侧角色
|
||||
var portal = PortalType.Tenant;
|
||||
|
||||
// 2. 获取租户上下文
|
||||
var tenantId = request.TenantId ?? tenantProvider.GetCurrentTenantId();
|
||||
// 2. (空行后) 校验租户参数
|
||||
if (!request.TenantId.HasValue || request.TenantId.Value <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId 不能为空");
|
||||
}
|
||||
|
||||
// 3. (空行后) 获取租户标识并删除角色
|
||||
var tenantId = request.TenantId.Value;
|
||||
|
||||
// 3. 删除角色
|
||||
await roleRepository.DeleteAsync(portal, tenantId, request.RoleId, cancellationToken);
|
||||
await roleRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 4. 返回执行结果
|
||||
// 4. (空行后) 返回执行结果
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Application.Identity.Queries;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
@@ -18,21 +15,13 @@ public sealed class GetIdentityUserDetailQueryHandler(
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IRolePermissionRepository rolePermissionRepository,
|
||||
IPermissionRepository permissionRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService)
|
||||
IPermissionRepository permissionRepository)
|
||||
: IRequestHandler<GetIdentityUserDetailQuery, UserDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<UserDetailDto?> Handle(GetIdentityUserDetailQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. 查询用户实体
|
||||
// 1. 查询用户实体
|
||||
var user = request.IncludeDeleted
|
||||
? await identityUserRepository.GetForUpdateIncludingDeletedAsync(request.UserId, cancellationToken)
|
||||
: await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
@@ -42,16 +31,11 @@ public sealed class GetIdentityUserDetailQueryHandler(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 加载角色与权限
|
||||
// 2. 加载角色与权限
|
||||
var portal = user.Portal;
|
||||
var tenantId = user.TenantId;
|
||||
|
||||
// 4. 查询用户角色关系
|
||||
// 3. 查询用户角色关系
|
||||
var roleRelations = await userRoleRepository.GetByUserIdAsync(portal, tenantId, user.Id, cancellationToken);
|
||||
var roleIds = roleRelations.Select(x => x.RoleId).Distinct().ToArray();
|
||||
var roles = roleIds.Length == 0
|
||||
@@ -75,7 +59,7 @@ public sealed class GetIdentityUserDetailQueryHandler(
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
// 5. 组装详情 DTO
|
||||
// 4. 组装详情 DTO
|
||||
var now = DateTime.UtcNow;
|
||||
return new UserDetailDto
|
||||
{
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Application.Identity.Queries;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 按用户 ID 获取权限概览处理器。
|
||||
/// </summary>
|
||||
public sealed class GetUserPermissionsQueryHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IPermissionRepository permissionRepository,
|
||||
IRolePermissionRepository rolePermissionRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetUserPermissionsQuery, UserPermissionDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<UserPermissionDto?> Handle(GetUserPermissionsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取租户并查询用户
|
||||
var portal = PortalType.Tenant;
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var user = await identityUserRepository.FindByIdAsync(request.UserId, cancellationToken);
|
||||
if (user == null || user.TenantId != tenantId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 解析角色与权限
|
||||
var roleCodes = await ResolveUserRolesAsync(portal, tenantId, user.Id, cancellationToken);
|
||||
var permissionCodes = await ResolveUserPermissionsAsync(portal, tenantId, user.Id, cancellationToken);
|
||||
|
||||
// 3. 返回用户权限概览
|
||||
return new UserPermissionDto
|
||||
{
|
||||
UserId = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
MerchantId = user.MerchantId,
|
||||
Account = user.Account,
|
||||
DisplayName = user.DisplayName,
|
||||
Roles = roleCodes,
|
||||
Permissions = permissionCodes,
|
||||
CreatedAt = user.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string[]> ResolveUserRolesAsync(PortalType portal, long tenantId, long userId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询用户角色关系
|
||||
var relations = await userRoleRepository.GetByUserIdAsync(portal, tenantId, userId, cancellationToken);
|
||||
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
|
||||
if (roleIds.Length == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
// 2. 查询角色编码
|
||||
var roles = await roleRepository.GetByIdsAsync(portal, tenantId, roleIds, cancellationToken);
|
||||
return roles.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
private async Task<string[]> ResolveUserPermissionsAsync(PortalType portal, long tenantId, long userId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询用户角色关系
|
||||
var relations = await userRoleRepository.GetByUserIdAsync(portal, tenantId, userId, cancellationToken);
|
||||
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
|
||||
if (roleIds.Length == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
// 2. 查询角色-权限关系
|
||||
var rolePermissions = await rolePermissionRepository.GetByRoleIdsAsync(portal, tenantId, roleIds, cancellationToken);
|
||||
var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray();
|
||||
if (permissionIds.Length == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
// 3. 查询权限编码
|
||||
var permissions = await permissionRepository.GetByIdsAsync(permissionIds, cancellationToken);
|
||||
return permissions.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 租户角色模板批量初始化处理器。
|
||||
/// </summary>
|
||||
public sealed class InitializeRoleTemplatesCommandHandler(
|
||||
IRoleTemplateRepository roleTemplateRepository,
|
||||
IMediator mediator)
|
||||
: IRequestHandler<InitializeRoleTemplatesCommand, IReadOnlyList<RoleDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RoleDto>> Handle(InitializeRoleTemplatesCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析需要初始化的模板编码,默认取全部模板。
|
||||
var requestedCodes = request.TemplateCodes?
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Select(code => code.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var availableTemplates = await roleTemplateRepository.GetAllAsync(true, cancellationToken);
|
||||
var availableCodes = availableTemplates.Select(t => t.TemplateCode).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var targetCodes = requestedCodes?.Length > 0
|
||||
? requestedCodes
|
||||
: availableTemplates.Select(template => template.TemplateCode).ToArray();
|
||||
|
||||
if (targetCodes.Length == 0)
|
||||
{
|
||||
return Array.Empty<RoleDto>();
|
||||
}
|
||||
|
||||
foreach (var code in targetCodes)
|
||||
{
|
||||
if (!availableCodes.Contains(code))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, $"角色模板 {code} 不存在或未启用");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 逐个复制模板,幂等写入角色与权限。
|
||||
var roles = new List<RoleDto>(targetCodes.Length);
|
||||
foreach (var templateCode in targetCodes)
|
||||
{
|
||||
var role = await mediator.Send(new CopyRoleTemplateCommand
|
||||
{
|
||||
TemplateCode = templateCode
|
||||
}, cancellationToken);
|
||||
|
||||
roles.Add(role);
|
||||
}
|
||||
|
||||
return roles;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
@@ -19,7 +18,6 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
public sealed class ResetIdentityUserPasswordCommandHandler(
|
||||
IAdminPasswordResetTokenStore tokenStore,
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IIdentityOperationLogPublisher operationLogPublisher)
|
||||
@@ -28,34 +26,21 @@ public sealed class ResetIdentityUserPasswordCommandHandler(
|
||||
/// <inheritdoc />
|
||||
public async Task<ResetIdentityUserPasswordResult> Handle(ResetIdentityUserPasswordCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
// 1. 获取操作者档案(用于操作日志)
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户重置密码");
|
||||
}
|
||||
|
||||
// 3. 查询用户实体
|
||||
// 2. (空行后) 查询用户实体
|
||||
var user = await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户重置密码");
|
||||
}
|
||||
|
||||
// 4. 签发重置令牌(1 小时有效)
|
||||
// 3. 签发重置令牌(1 小时有效)
|
||||
var expiresAt = DateTime.UtcNow.AddHours(1);
|
||||
var token = await tokenStore.IssueAsync(user.Id, expiresAt, cancellationToken);
|
||||
|
||||
// 5. 标记用户需重置密码
|
||||
// 4. 标记用户需重置密码
|
||||
user.MustChangePassword = true;
|
||||
user.FailedLoginCount = 0;
|
||||
user.LockedUntil = null;
|
||||
@@ -64,7 +49,7 @@ public sealed class ResetIdentityUserPasswordCommandHandler(
|
||||
user.Status = IdentityUserStatus.Active;
|
||||
}
|
||||
|
||||
// 6. 构建操作日志消息
|
||||
// 5. 构建操作日志消息
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? operatorProfile.Account
|
||||
: operatorProfile.DisplayName;
|
||||
@@ -85,7 +70,7 @@ public sealed class ResetIdentityUserPasswordCommandHandler(
|
||||
Success = true
|
||||
};
|
||||
|
||||
// 7. 写入 Outbox 并保存变更
|
||||
// 6. 写入 Outbox 并保存变更
|
||||
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
@@ -16,7 +15,6 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
/// </summary>
|
||||
public sealed class RestoreIdentityUserCommandHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IIdentityOperationLogPublisher operationLogPublisher)
|
||||
@@ -25,35 +23,22 @@ public sealed class RestoreIdentityUserCommandHandler(
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> Handle(RestoreIdentityUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
// 1. 获取操作者档案(用于操作日志)
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户恢复用户");
|
||||
}
|
||||
|
||||
// 3. 查询用户实体(包含已删除)
|
||||
// 2. (空行后) 查询用户实体(包含已删除)
|
||||
var user = await identityUserRepository.GetForUpdateIncludingDeletedAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!user.DeletedAt.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. 构建操作日志消息
|
||||
// 3. (空行后) 构建操作日志消息
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? operatorProfile.Account
|
||||
: operatorProfile.DisplayName;
|
||||
@@ -74,7 +59,7 @@ public sealed class RestoreIdentityUserCommandHandler(
|
||||
Success = true
|
||||
};
|
||||
|
||||
// 5. 恢复软删除状态并写入 Outbox
|
||||
// 4. 恢复软删除状态并写入 Outbox
|
||||
user.DeletedAt = null;
|
||||
user.DeletedBy = null;
|
||||
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
|
||||
|
||||
@@ -3,7 +3,8 @@ using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Application.Identity.Queries;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
@@ -13,8 +14,7 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
public sealed class RoleDetailQueryHandler(
|
||||
IRoleRepository roleRepository,
|
||||
IRolePermissionRepository rolePermissionRepository,
|
||||
IPermissionRepository permissionRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
IPermissionRepository permissionRepository)
|
||||
: IRequestHandler<RoleDetailQuery, RoleDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
@@ -23,24 +23,30 @@ public sealed class RoleDetailQueryHandler(
|
||||
// 1. 固定查询租户侧角色详情
|
||||
var portal = PortalType.Tenant;
|
||||
|
||||
// 2. 获取租户上下文并查询角色
|
||||
var tenantId = request.TenantId ?? tenantProvider.GetCurrentTenantId();
|
||||
// 2. (空行后) 校验租户参数
|
||||
if (!request.TenantId.HasValue || request.TenantId.Value <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId 不能为空");
|
||||
}
|
||||
|
||||
// 3. (空行后) 获取租户标识并查询角色
|
||||
var tenantId = request.TenantId.Value;
|
||||
var role = await roleRepository.FindByIdAsync(portal, tenantId, request.RoleId, cancellationToken);
|
||||
if (role is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 查询角色权限关系
|
||||
// 4. 查询角色权限关系
|
||||
var relations = await rolePermissionRepository.GetByRoleIdsAsync(portal, tenantId, new[] { role.Id }, cancellationToken);
|
||||
var permissionIds = relations.Select(x => x.PermissionId).ToArray();
|
||||
|
||||
// 4. 拉取权限实体
|
||||
// 5. 拉取权限实体
|
||||
var permissions = permissionIds.Length == 0
|
||||
? Array.Empty<Domain.Identity.Entities.Permission>()
|
||||
: await permissionRepository.GetByIdsAsync(permissionIds, cancellationToken);
|
||||
|
||||
// 5. 映射 DTO
|
||||
// 6. 映射 DTO
|
||||
var permissionDtos = permissions
|
||||
.Select(x => new PermissionDto
|
||||
{
|
||||
@@ -55,6 +61,7 @@ public sealed class RoleDetailQueryHandler(
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// 7. (空行后) 返回角色详情
|
||||
return new RoleDetailDto
|
||||
{
|
||||
Id = role.Id,
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Application.Identity.Queries;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
@@ -19,30 +14,16 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
public sealed class SearchIdentityUsersQueryHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService)
|
||||
IRoleRepository roleRepository)
|
||||
: IRequestHandler<SearchIdentityUsersQuery, PagedResult<UserListItemDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<UserListItemDto>> Handle(SearchIdentityUsersQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户查询用户");
|
||||
}
|
||||
|
||||
// 3. 组装查询过滤条件
|
||||
// 1. 组装查询过滤条件
|
||||
var filter = new IdentityUserSearchFilter
|
||||
{
|
||||
TenantId = isSuperAdmin ? request.TenantId : currentTenantId,
|
||||
TenantId = request.TenantId,
|
||||
Keyword = request.Keyword,
|
||||
Status = request.Status,
|
||||
RoleId = request.RoleId,
|
||||
@@ -57,17 +38,17 @@ public sealed class SearchIdentityUsersQueryHandler(
|
||||
SortDescending = request.SortDescending
|
||||
};
|
||||
|
||||
// 4. 执行分页查询
|
||||
// 2. 执行分页查询
|
||||
var (items, total) = await identityUserRepository.SearchPagedAsync(filter, cancellationToken);
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return new PagedResult<UserListItemDto>(Array.Empty<UserListItemDto>(), request.Page, request.PageSize, total);
|
||||
}
|
||||
|
||||
// 5. 加载角色编码映射
|
||||
// 3. 加载角色编码映射
|
||||
var roleCodesLookup = await ResolveRoleCodesAsync(items, userRoleRepository, roleRepository, cancellationToken);
|
||||
|
||||
// 6. 组装 DTO
|
||||
// 4. 组装 DTO
|
||||
var now = DateTime.UtcNow;
|
||||
var dtos = items.Select(user => new UserListItemDto
|
||||
{
|
||||
@@ -87,7 +68,7 @@ public sealed class SearchIdentityUsersQueryHandler(
|
||||
LastLoginAt = user.LastLoginAt
|
||||
}).ToList();
|
||||
|
||||
// 7. 返回分页结果
|
||||
// 5. 返回分页结果
|
||||
return new PagedResult<UserListItemDto>(dtos, request.Page, request.PageSize, total);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Application.Identity.Queries;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
@@ -12,8 +13,7 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
/// 角色分页查询处理器。
|
||||
/// </summary>
|
||||
public sealed class SearchRolesQueryHandler(
|
||||
IRoleRepository roleRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
IRoleRepository roleRepository)
|
||||
: IRequestHandler<SearchRolesQuery, PagedResult<RoleDto>>
|
||||
{
|
||||
/// <summary>
|
||||
@@ -27,11 +27,17 @@ public sealed class SearchRolesQueryHandler(
|
||||
// 1. 固定查询租户侧角色
|
||||
var portal = PortalType.Tenant;
|
||||
|
||||
// 2. 获取租户上下文并查询角色
|
||||
var tenantId = request.TenantId ?? tenantProvider.GetCurrentTenantId();
|
||||
// 2. (空行后) 校验租户参数
|
||||
if (!request.TenantId.HasValue || request.TenantId.Value <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId 不能为空");
|
||||
}
|
||||
|
||||
// 3. (空行后) 获取租户标识并查询角色
|
||||
var tenantId = request.TenantId.Value;
|
||||
var roles = await roleRepository.SearchAsync(portal, tenantId, request.Keyword, cancellationToken);
|
||||
|
||||
// 3. 排序
|
||||
// 4. 排序
|
||||
var sorted = request.SortBy?.ToLowerInvariant() switch
|
||||
{
|
||||
"name" => request.SortDescending
|
||||
@@ -42,13 +48,13 @@ public sealed class SearchRolesQueryHandler(
|
||||
: roles.OrderBy(x => x.CreatedAt)
|
||||
};
|
||||
|
||||
// 4. 分页
|
||||
// 5. 分页
|
||||
var paged = sorted
|
||||
.Skip((request.Page - 1) * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.ToList();
|
||||
|
||||
// 5. 映射 DTO
|
||||
// 6. 映射 DTO
|
||||
var items = paged.Select(role => new RoleDto
|
||||
{
|
||||
Id = role.Id,
|
||||
@@ -59,7 +65,7 @@ public sealed class SearchRolesQueryHandler(
|
||||
Description = role.Description
|
||||
}).ToList();
|
||||
|
||||
// 6. 返回分页结果
|
||||
// 7. 返回分页结果
|
||||
return new PagedResult<RoleDto>(items, request.Page, request.PageSize, roles.Count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Application.Identity.Queries;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 租户用户权限分页查询处理器。
|
||||
/// </summary>
|
||||
public sealed class SearchUserPermissionsQueryHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IPermissionRepository permissionRepository,
|
||||
IRolePermissionRepository rolePermissionRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SearchUserPermissionsQuery, PagedResult<UserPermissionDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<UserPermissionDto>> Handle(SearchUserPermissionsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取租户并查询用户
|
||||
var portal = PortalType.Tenant;
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var users = await identityUserRepository.SearchAsync(tenantId, request.Keyword, cancellationToken);
|
||||
|
||||
// 2. 排序与分页
|
||||
var sorted = SortUsers(users, request.SortBy, request.SortDescending);
|
||||
var paged = sorted
|
||||
.Skip((request.Page - 1) * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.ToList();
|
||||
|
||||
// 3. 解析角色与权限
|
||||
var resolved = await ResolveRolesAndPermissionsAsync(portal, tenantId, paged, cancellationToken);
|
||||
var items = paged.Select(user => new UserPermissionDto
|
||||
{
|
||||
UserId = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
MerchantId = user.MerchantId,
|
||||
Account = user.Account,
|
||||
DisplayName = user.DisplayName,
|
||||
Roles = resolved[user.Id].roles,
|
||||
Permissions = resolved[user.Id].permissions,
|
||||
CreatedAt = user.CreatedAt
|
||||
}).ToList();
|
||||
|
||||
return new PagedResult<UserPermissionDto>(items, request.Page, request.PageSize, users.Count);
|
||||
}
|
||||
|
||||
private static IOrderedEnumerable<Domain.Identity.Entities.IdentityUser> SortUsers(
|
||||
IReadOnlyCollection<Domain.Identity.Entities.IdentityUser> users,
|
||||
string? sortBy,
|
||||
bool sortDescending)
|
||||
{
|
||||
return sortBy?.ToLowerInvariant() switch
|
||||
{
|
||||
"account" => sortDescending
|
||||
? users.OrderByDescending(x => x.Account)
|
||||
: users.OrderBy(x => x.Account),
|
||||
"displayname" => sortDescending
|
||||
? users.OrderByDescending(x => x.DisplayName)
|
||||
: users.OrderBy(x => x.DisplayName),
|
||||
_ => sortDescending
|
||||
? users.OrderByDescending(x => x.CreatedAt)
|
||||
: users.OrderBy(x => x.CreatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<Dictionary<long, (string[] roles, string[] permissions)>> ResolveRolesAndPermissionsAsync(
|
||||
PortalType portal,
|
||||
long tenantId,
|
||||
IReadOnlyCollection<Domain.Identity.Entities.IdentityUser> users,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询用户角色关系
|
||||
var userIds = users.Select(x => x.Id).ToArray();
|
||||
var userRoleRelations = await userRoleRepository.GetByUserIdsAsync(portal, tenantId, userIds, cancellationToken);
|
||||
var roleIds = userRoleRelations.Select(x => x.RoleId).Distinct().ToArray();
|
||||
|
||||
// 2. 查询角色信息
|
||||
var roles = roleIds.Length == 0
|
||||
? Array.Empty<Domain.Identity.Entities.Role>()
|
||||
: await roleRepository.GetByIdsAsync(portal, tenantId, roleIds, cancellationToken);
|
||||
var roleCodeMap = roles.ToDictionary(r => r.Id, r => r.Code, comparer: EqualityComparer<long>.Default);
|
||||
|
||||
// 3. 查询角色-权限关系
|
||||
var rolePermissions = roleIds.Length == 0
|
||||
? Array.Empty<Domain.Identity.Entities.RolePermission>()
|
||||
: await rolePermissionRepository.GetByRoleIdsAsync(portal, tenantId, roleIds, cancellationToken);
|
||||
var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray();
|
||||
|
||||
// 4. 查询权限详情
|
||||
var permissions = permissionIds.Length == 0
|
||||
? Array.Empty<Domain.Identity.Entities.Permission>()
|
||||
: await permissionRepository.GetByIdsAsync(permissionIds, cancellationToken);
|
||||
var permissionCodeMap = permissions.ToDictionary(p => p.Id, p => p.Code, comparer: EqualityComparer<long>.Default);
|
||||
|
||||
var rolePermissionsLookup = rolePermissions
|
||||
.GroupBy(rp => rp.RoleId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(rp => rp.PermissionId).ToArray(), comparer: EqualityComparer<long>.Default);
|
||||
|
||||
var result = new Dictionary<long, (string[] roles, string[] permissions)>();
|
||||
foreach (var userId in userIds)
|
||||
{
|
||||
// 5. 聚合用户角色与权限编码
|
||||
var rolesForUser = userRoleRelations.Where(ur => ur.UserId == userId).Select(ur => ur.RoleId).Distinct().ToArray();
|
||||
var roleCodes = rolesForUser
|
||||
.Select(rid => roleCodeMap.GetValueOrDefault(rid))
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c))
|
||||
.Select(c => c!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var permissionCodes = rolesForUser
|
||||
.SelectMany(rid => rolePermissionsLookup.GetValueOrDefault(rid) ?? Array.Empty<long>())
|
||||
.Select(pid => permissionCodeMap.GetValueOrDefault(pid))
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Select(code => code!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
result[userId] = (roleCodes, permissionCodes);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
@@ -22,7 +21,6 @@ public sealed class UpdateIdentityUserCommandHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IIdentityOperationLogPublisher operationLogPublisher,
|
||||
@@ -32,30 +30,17 @@ public sealed class UpdateIdentityUserCommandHandler(
|
||||
/// <inheritdoc />
|
||||
public async Task<UserDetailDto?> Handle(UpdateIdentityUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
// 1. 获取操作者档案(用于操作日志)
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户更新用户");
|
||||
}
|
||||
|
||||
// 3. 获取用户实体
|
||||
// 2. (空行后) 获取用户实体
|
||||
var user = await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4. 规范化输入并校验唯一性
|
||||
// 3. (空行后) 规范化输入并校验唯一性
|
||||
var portal = user.Portal;
|
||||
var tenantId = user.TenantId;
|
||||
if (portal == PortalType.Tenant && (!tenantId.HasValue || tenantId.Value == 0))
|
||||
@@ -93,14 +78,14 @@ public sealed class UpdateIdentityUserCommandHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 更新用户字段
|
||||
// 4. 更新用户字段
|
||||
user.DisplayName = displayName;
|
||||
user.Phone = phone;
|
||||
user.Email = email;
|
||||
user.Avatar = string.IsNullOrWhiteSpace(request.Avatar) ? null : request.Avatar.Trim();
|
||||
user.RowVersion = request.RowVersion;
|
||||
|
||||
// 6. 构建操作日志消息
|
||||
// 5. 构建操作日志消息
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? operatorProfile.Account
|
||||
: operatorProfile.DisplayName;
|
||||
@@ -128,7 +113,7 @@ public sealed class UpdateIdentityUserCommandHandler(
|
||||
Success = true
|
||||
};
|
||||
|
||||
// 7. 持久化用户更新并写入 Outbox
|
||||
// 6. 持久化用户更新并写入 Outbox
|
||||
try
|
||||
{
|
||||
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
|
||||
@@ -139,13 +124,13 @@ public sealed class UpdateIdentityUserCommandHandler(
|
||||
throw new BusinessException(ErrorCodes.Conflict, "用户数据已被修改,请刷新后重试");
|
||||
}
|
||||
|
||||
// 8. 覆盖角色绑定(仅当显式传入时)
|
||||
// 7. 覆盖角色绑定(仅当显式传入时)
|
||||
if (roleIds != null)
|
||||
{
|
||||
await userRoleRepository.ReplaceUserRolesAsync(portal, tenantId, user.Id, roleIds, cancellationToken);
|
||||
}
|
||||
|
||||
// 9. 返回用户详情
|
||||
// 8. 返回用户详情
|
||||
return await mediator.Send(new GetIdentityUserDetailQuery { UserId = user.Id }, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
@@ -11,8 +12,7 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
/// 更新角色处理器。
|
||||
/// </summary>
|
||||
public sealed class UpdateRoleCommandHandler(
|
||||
IRoleRepository roleRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
IRoleRepository roleRepository)
|
||||
: IRequestHandler<UpdateRoleCommand, RoleDto?>
|
||||
{
|
||||
/// <summary>
|
||||
@@ -26,23 +26,29 @@ public sealed class UpdateRoleCommandHandler(
|
||||
// 1. 固定更新租户侧角色
|
||||
var portal = PortalType.Tenant;
|
||||
|
||||
// 2. 获取租户上下文并查询角色
|
||||
var tenantId = request.TenantId ?? tenantProvider.GetCurrentTenantId();
|
||||
// 2. (空行后) 校验租户参数
|
||||
if (!request.TenantId.HasValue || request.TenantId.Value <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId 不能为空");
|
||||
}
|
||||
|
||||
// 3. (空行后) 获取租户标识并查询角色
|
||||
var tenantId = request.TenantId.Value;
|
||||
var role = await roleRepository.FindByIdAsync(portal, tenantId, request.RoleId, cancellationToken);
|
||||
if (role == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 更新字段
|
||||
// 4. 更新字段
|
||||
role.Name = request.Name;
|
||||
role.Description = request.Description;
|
||||
|
||||
// 4. 持久化
|
||||
// 5. 持久化
|
||||
await roleRepository.UpdateAsync(role, cancellationToken);
|
||||
await roleRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 5. 返回 DTO
|
||||
// 6. 返回 DTO
|
||||
return new RoleDto
|
||||
{
|
||||
Id = role.Id,
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 按用户 ID 获取角色/权限概览。
|
||||
/// </summary>
|
||||
public sealed class GetUserPermissionsQuery : IRequest<UserPermissionDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID(雪花)。
|
||||
/// </summary>
|
||||
public long UserId { get; init; }
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 按租户分页查询用户的角色/权限概览。
|
||||
/// </summary>
|
||||
public sealed class SearchUserPermissionsQuery : IRequest<PagedResult<UserPermissionDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 关键字(账号或展示名称)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码,从 1 开始。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// 排序字段(account/displayName/createdAt)。
|
||||
/// </summary>
|
||||
public string? SortBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否倒序。
|
||||
/// </summary>
|
||||
public bool SortDescending { get; init; } = true;
|
||||
}
|
||||
@@ -6,8 +6,6 @@ using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Services;
|
||||
|
||||
@@ -23,8 +21,7 @@ public sealed class AdminAuthService(
|
||||
IMenuRepository menuRepository,
|
||||
IPasswordHasher<IdentityUser> passwordHasher,
|
||||
IJwtTokenService jwtTokenService,
|
||||
IRefreshTokenStore refreshTokenStore,
|
||||
ITenantProvider tenantProvider) : IAdminAuthService
|
||||
IRefreshTokenStore refreshTokenStore) : IAdminAuthService
|
||||
{
|
||||
/// <summary>
|
||||
/// 管理后台登录:验证账号密码并生成令牌。
|
||||
@@ -159,92 +156,6 @@ public sealed class AdminAuthService(
|
||||
return menu;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定用户的权限概览(校验当前租户)。
|
||||
/// </summary>
|
||||
public async Task<UserPermissionDto?> GetUserPermissionsAsync(long userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var user = await userRepository.FindByIdAsync(userId, cancellationToken);
|
||||
if (user == null || user.TenantId != tenantId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. 解析角色集合
|
||||
var roleCodes = await ResolveUserRolesAsync(user.Portal, user.TenantId, user.Id, cancellationToken);
|
||||
// 2. (空行后) 解析权限集合
|
||||
var permissionCodes = await ResolveUserPermissionsAsync(user.Portal, user.TenantId, user.Id, cancellationToken);
|
||||
|
||||
// 3. (空行后) 返回概览
|
||||
return new UserPermissionDto
|
||||
{
|
||||
UserId = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
MerchantId = user.MerchantId,
|
||||
Account = user.Account,
|
||||
DisplayName = user.DisplayName,
|
||||
Roles = roleCodes,
|
||||
Permissions = permissionCodes,
|
||||
CreatedAt = user.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按租户分页查询用户权限概览。
|
||||
/// </summary>
|
||||
public async Task<PagedResult<UserPermissionDto>> SearchUserPermissionsAsync(
|
||||
string? keyword,
|
||||
int page,
|
||||
int pageSize,
|
||||
string? sortBy,
|
||||
bool sortDescending,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 获取当前租户
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
// 2. (空行后) 查询用户列表
|
||||
var users = await userRepository.SearchAsync(tenantId, keyword, cancellationToken);
|
||||
|
||||
// 3. (空行后) 排序
|
||||
var sorted = sortBy?.ToLowerInvariant() switch
|
||||
{
|
||||
"account" => sortDescending
|
||||
? users.OrderByDescending(x => x.Account)
|
||||
: users.OrderBy(x => x.Account),
|
||||
"displayname" => sortDescending
|
||||
? users.OrderByDescending(x => x.DisplayName)
|
||||
: users.OrderBy(x => x.DisplayName),
|
||||
_ => sortDescending
|
||||
? users.OrderByDescending(x => x.CreatedAt)
|
||||
: users.OrderBy(x => x.CreatedAt)
|
||||
};
|
||||
|
||||
// 4. (空行后) 分页
|
||||
var paged = sorted
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
|
||||
// 5. (空行后) 解析角色与权限
|
||||
var resolved = await ResolveRolesAndPermissionsAsync(PortalType.Tenant, tenantId, paged, cancellationToken);
|
||||
// 6. (空行后) 映射为 DTO
|
||||
var items = paged.Select(user => new UserPermissionDto
|
||||
{
|
||||
UserId = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
MerchantId = user.MerchantId,
|
||||
Account = user.Account,
|
||||
DisplayName = user.DisplayName,
|
||||
Roles = resolved[user.Id].roles,
|
||||
Permissions = resolved[user.Id].permissions,
|
||||
CreatedAt = user.CreatedAt
|
||||
}).ToList();
|
||||
|
||||
// 7. (空行后) 返回分页结果
|
||||
return new PagedResult<UserPermissionDto>(items, page, pageSize, users.Count);
|
||||
}
|
||||
|
||||
private async Task<CurrentUserProfile> BuildProfileAsync(IdentityUser user, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析角色集合
|
||||
@@ -495,68 +406,4 @@ public sealed class AdminAuthService(
|
||||
var permissions = await permissionRepository.GetByIdsAsync(permissionIds, cancellationToken);
|
||||
return permissions.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
private async Task<Dictionary<long, (string[] roles, string[] permissions)>> ResolveRolesAndPermissionsAsync(
|
||||
PortalType portal,
|
||||
long? tenantId,
|
||||
IReadOnlyCollection<IdentityUser> users,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取用户-角色关系
|
||||
var userIds = users.Select(x => x.Id).ToArray();
|
||||
var userRoleRelations = await userRoleRepository.GetByUserIdsAsync(portal, tenantId, userIds, cancellationToken);
|
||||
var roleIds = userRoleRelations.Select(x => x.RoleId).Distinct().ToArray();
|
||||
|
||||
// 2. (空行后) 读取角色定义
|
||||
var roles = roleIds.Length == 0
|
||||
? Array.Empty<Role>()
|
||||
: await roleRepository.GetByIdsAsync(portal, tenantId, roleIds, cancellationToken);
|
||||
var roleCodeMap = roles.ToDictionary(r => r.Id, r => r.Code, comparer: EqualityComparer<long>.Default);
|
||||
|
||||
// 3. (空行后) 读取角色-权限关系
|
||||
var rolePermissions = roleIds.Length == 0
|
||||
? Array.Empty<RolePermission>()
|
||||
: await rolePermissionRepository.GetByRoleIdsAsync(portal, tenantId, roleIds, cancellationToken);
|
||||
|
||||
// 4. (空行后) 读取权限定义
|
||||
var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray();
|
||||
var permissions = permissionIds.Length == 0
|
||||
? Array.Empty<Permission>()
|
||||
: await permissionRepository.GetByIdsAsync(permissionIds, cancellationToken);
|
||||
var permissionCodeMap = permissions.ToDictionary(p => p.Id, p => p.Code, comparer: EqualityComparer<long>.Default);
|
||||
|
||||
// 5. (空行后) 构建 Role -> PermissionId[] 映射
|
||||
var rolePermissionsLookup = rolePermissions
|
||||
.GroupBy(rp => rp.RoleId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(rp => rp.PermissionId).ToArray(), comparer: EqualityComparer<long>.Default);
|
||||
|
||||
// 6. (空行后) 按用户聚合角色码与权限码
|
||||
var result = new Dictionary<long, (string[] roles, string[] permissions)>();
|
||||
foreach (var userId in userIds)
|
||||
{
|
||||
// 6.1 解析用户角色码
|
||||
var rolesForUser = userRoleRelations.Where(ur => ur.UserId == userId).Select(ur => ur.RoleId).Distinct().ToArray();
|
||||
var roleCodes = rolesForUser
|
||||
.Select(rid => roleCodeMap.GetValueOrDefault(rid))
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c))
|
||||
.Select(c => c!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
// 6.2 (空行后) 解析用户权限码
|
||||
var permissionCodes = rolesForUser
|
||||
.SelectMany(rid => rolePermissionsLookup.GetValueOrDefault(rid) ?? Array.Empty<long>())
|
||||
.Select(pid => permissionCodeMap.GetValueOrDefault(pid))
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Select(code => code!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
// 6.3 (空行后) 写入结果
|
||||
result[userId] = (roleCodes, permissionCodes);
|
||||
}
|
||||
|
||||
// 7. (空行后) 返回聚合结果
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.Net;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
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;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 小程序认证服务实现。
|
||||
/// </summary>
|
||||
public sealed class MiniAuthService(
|
||||
IWeChatAuthService weChatAuthService,
|
||||
IMiniUserRepository miniUserRepository,
|
||||
IJwtTokenService jwtTokenService,
|
||||
IRefreshTokenStore refreshTokenStore,
|
||||
ILoginRateLimiter rateLimiter,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ITenantProvider tenantProvider) : IMiniAuthService
|
||||
{
|
||||
/// <summary>
|
||||
/// 微信小程序登录:通过微信 code 获取用户信息并生成令牌。
|
||||
/// </summary>
|
||||
/// <param name="request">微信登录请求</param>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
/// <returns>令牌响应</returns>
|
||||
/// <exception cref="BusinessException">获取微信用户信息失败、缺少租户标识时抛出</exception>
|
||||
public async Task<TokenResponse> LoginWithWeChatAsync(WeChatLoginRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 限流检查(基于 IP 地址)
|
||||
var throttleKey = BuildThrottleKey();
|
||||
await rateLimiter.EnsureAllowedAsync(throttleKey, cancellationToken);
|
||||
|
||||
// 2. 通过微信 code 获取 session(OpenId、UnionId、SessionKey)
|
||||
var session = await weChatAuthService.Code2SessionAsync(request.Code, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(session.OpenId))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "获取微信用户信息失败");
|
||||
}
|
||||
|
||||
// 3. 获取当前租户 ID(多租户支持)
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (tenantId == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识");
|
||||
}
|
||||
|
||||
// 4. 获取或创建小程序用户(如果 OpenId 已存在则返回现有用户,否则创建新用户)
|
||||
var (user, isNew) = await GetOrBindMiniUserAsync(session.OpenId, session.UnionId, request.Nickname, request.Avatar, tenantId, cancellationToken);
|
||||
|
||||
// 5. 登录成功后重置限流计数
|
||||
await rateLimiter.ResetAsync(throttleKey, cancellationToken);
|
||||
|
||||
// 6. 构建用户档案并生成令牌
|
||||
var profile = BuildProfile(user);
|
||||
return await jwtTokenService.CreateTokensAsync(profile, isNew, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 刷新访问令牌:使用刷新令牌获取新的访问令牌和刷新令牌。
|
||||
/// </summary>
|
||||
/// <param name="request">刷新令牌请求</param>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
/// <returns>新的令牌响应</returns>
|
||||
/// <exception cref="BusinessException">刷新令牌无效、已过期或用户不存在时抛出</exception>
|
||||
public async Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 验证刷新令牌(检查是否存在、是否过期、是否已撤销)
|
||||
var descriptor = await refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken);
|
||||
if (descriptor == null || descriptor.ExpiresAt <= DateTime.UtcNow || descriptor.Revoked)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "RefreshToken 无效或已过期");
|
||||
}
|
||||
|
||||
// 2. 根据用户 ID 查找用户
|
||||
var user = await miniUserRepository.FindByIdAsync(descriptor.UserId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.Unauthorized, "用户不存在");
|
||||
|
||||
// 3. 撤销旧刷新令牌(防止重复使用)
|
||||
await refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken);
|
||||
|
||||
// 4. 生成新的令牌对
|
||||
var profile = BuildProfile(user);
|
||||
return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户档案。
|
||||
/// </summary>
|
||||
/// <param name="userId">用户 ID</param>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
/// <returns>用户档案</returns>
|
||||
/// <exception cref="BusinessException">用户不存在时抛出</exception>
|
||||
public async Task<CurrentUserProfile> GetProfileAsync(long userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await miniUserRepository.FindByIdAsync(userId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
|
||||
|
||||
return BuildProfile(user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取或绑定小程序用户:如果 OpenId 已存在则返回现有用户,否则创建新用户。
|
||||
/// </summary>
|
||||
/// <param name="openId">微信 OpenId</param>
|
||||
/// <param name="unionId">微信 UnionId(可选)</param>
|
||||
/// <param name="nickname">昵称</param>
|
||||
/// <param name="avatar">头像地址(可选)</param>
|
||||
/// <param name="tenantId">租户 ID</param>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
/// <returns>用户实体和是否为新用户的元组</returns>
|
||||
private async Task<(MiniUser user, bool isNew)> GetOrBindMiniUserAsync(string openId, string? unionId, string? nickname, string? avatar, long tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 检查用户是否已存在
|
||||
var existing = await miniUserRepository.FindByOpenIdAsync(openId, cancellationToken);
|
||||
if (existing != null)
|
||||
{
|
||||
return (existing, false);
|
||||
}
|
||||
|
||||
// 创建新用户
|
||||
var created = await miniUserRepository.CreateOrUpdateAsync(openId, unionId, nickname, avatar, tenantId, cancellationToken);
|
||||
return (created, true);
|
||||
}
|
||||
|
||||
private static CurrentUserProfile BuildProfile(MiniUser user)
|
||||
=> new()
|
||||
{
|
||||
UserId = user.Id,
|
||||
Account = user.OpenId,
|
||||
DisplayName = user.Nickname,
|
||||
TenantId = user.TenantId,
|
||||
MerchantId = null,
|
||||
Roles = Array.Empty<string>(),
|
||||
Permissions = Array.Empty<string>(),
|
||||
Avatar = user.Avatar
|
||||
};
|
||||
|
||||
private string BuildThrottleKey()
|
||||
{
|
||||
var ip = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress ?? IPAddress.Loopback;
|
||||
return $"mini-login:{ip}";
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
using TakeoutSaaS.Application.Sms.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Sms.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 短信验证码服务抽象。
|
||||
/// </summary>
|
||||
public interface IVerificationCodeService
|
||||
{
|
||||
/// <summary>
|
||||
/// 发送验证码。
|
||||
/// </summary>
|
||||
Task<SendVerificationCodeResponse> SendAsync(SendVerificationCodeRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 校验验证码。
|
||||
/// </summary>
|
||||
Task<bool> VerifyAsync(VerifyVerificationCodeRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using TakeoutSaaS.Module.Sms;
|
||||
|
||||
namespace TakeoutSaaS.Application.Sms.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 发送验证码请求。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 创建发送请求。
|
||||
/// </remarks>
|
||||
public sealed class SendVerificationCodeRequest(string phoneNumber, string scene, SmsProviderKind? provider = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// 手机号(支持 +86 前缀或纯 11 位)。
|
||||
/// </summary>
|
||||
public string PhoneNumber { get; } = phoneNumber;
|
||||
|
||||
/// <summary>
|
||||
/// 业务场景(如 login/register/reset)。
|
||||
/// </summary>
|
||||
public string Scene { get; } = scene;
|
||||
|
||||
/// <summary>
|
||||
/// 指定服务商,未指定则使用默认配置。
|
||||
/// </summary>
|
||||
public SmsProviderKind? Provider { get; } = provider;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
namespace TakeoutSaaS.Application.Sms.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 发送验证码响应。
|
||||
/// </summary>
|
||||
public sealed class SendVerificationCodeResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 过期时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 请求标识。
|
||||
/// </summary>
|
||||
public string? RequestId { get; set; }
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
namespace TakeoutSaaS.Application.Sms.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 校验验证码请求。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 创建校验请求。
|
||||
/// </remarks>
|
||||
public sealed class VerifyVerificationCodeRequest(string phoneNumber, string scene, string code)
|
||||
{
|
||||
/// <summary>
|
||||
/// 手机号。
|
||||
/// </summary>
|
||||
public string PhoneNumber { get; } = phoneNumber;
|
||||
|
||||
/// <summary>
|
||||
/// 业务场景。
|
||||
/// </summary>
|
||||
public string Scene { get; } = scene;
|
||||
|
||||
/// <summary>
|
||||
/// 填写的验证码。
|
||||
/// </summary>
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Application.Sms.Abstractions;
|
||||
using TakeoutSaaS.Application.Sms.Options;
|
||||
using TakeoutSaaS.Application.Sms.Services;
|
||||
|
||||
namespace TakeoutSaaS.Application.Sms.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 短信应用服务注册扩展。
|
||||
/// </summary>
|
||||
public static class SmsServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册短信验证码应用服务。
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSmsApplication(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<VerificationCodeOptions>()
|
||||
.Bind(configuration.GetSection("Sms:VerificationCode"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddScoped<IVerificationCodeService, VerificationCodeService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.Sms.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 验证码发送配置。
|
||||
/// </summary>
|
||||
public sealed class VerificationCodeOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证码位数,默认 6。
|
||||
/// </summary>
|
||||
[Range(4, 10)]
|
||||
public int CodeLength { get; set; } = 6;
|
||||
|
||||
/// <summary>
|
||||
/// 过期时间(分钟)。
|
||||
/// </summary>
|
||||
[Range(1, 60)]
|
||||
public int ExpireMinutes { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// 发送冷却时间(秒),用于防止频繁请求。
|
||||
/// </summary>
|
||||
[Range(10, 300)]
|
||||
public int CooldownSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// 缓存前缀。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string CachePrefix { get; set; } = "sms:code";
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using TakeoutSaaS.Application.Sms.Abstractions;
|
||||
using TakeoutSaaS.Application.Sms.Contracts;
|
||||
using TakeoutSaaS.Application.Sms.Options;
|
||||
using TakeoutSaaS.Module.Sms.Abstractions;
|
||||
using TakeoutSaaS.Module.Sms.Models;
|
||||
using TakeoutSaaS.Module.Sms.Options;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Sms.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 短信验证码服务实现。
|
||||
/// </summary>
|
||||
public sealed class VerificationCodeService(
|
||||
ISmsSenderResolver senderResolver,
|
||||
IOptionsMonitor<SmsOptions> smsOptionsMonitor,
|
||||
IOptionsMonitor<VerificationCodeOptions> codeOptionsMonitor,
|
||||
ITenantProvider tenantProvider,
|
||||
IDistributedCache cache,
|
||||
ILogger<VerificationCodeService> logger) : IVerificationCodeService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<SendVerificationCodeResponse> SendAsync(SendVerificationCodeRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 参数校验
|
||||
if (string.IsNullOrWhiteSpace(request.PhoneNumber))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "手机号不能为空");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Scene))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "场景不能为空");
|
||||
}
|
||||
|
||||
// 2. 解析模板与缓存键
|
||||
var smsOptions = smsOptionsMonitor.CurrentValue;
|
||||
var codeOptions = codeOptionsMonitor.CurrentValue;
|
||||
var templateCode = ResolveTemplate(request.Scene, smsOptions);
|
||||
var phone = NormalizePhoneNumber(request.PhoneNumber);
|
||||
var tenantKey = tenantProvider.GetCurrentTenantId() == 0 ? "platform" : tenantProvider.GetCurrentTenantId().ToString();
|
||||
var cacheKey = $"{codeOptions.CachePrefix}:{tenantKey}:{request.Scene}:{phone}";
|
||||
var cooldownKey = $"{cacheKey}:cooldown";
|
||||
|
||||
// 3. 检查冷却期
|
||||
await EnsureCooldownAsync(cooldownKey, codeOptions.CooldownSeconds, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 4. 生成验证码并发送短信
|
||||
var code = GenerateCode(codeOptions.CodeLength);
|
||||
var variables = new Dictionary<string, string> { { "code", code } };
|
||||
var sender = senderResolver.Resolve(request.Provider);
|
||||
|
||||
var smsRequest = new SmsSendRequest(phone, templateCode, variables, smsOptions.DefaultSignName);
|
||||
var smsResult = await sender.SendAsync(smsRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!smsResult.Success)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.InternalServerError, $"短信发送失败:{smsResult.Message}");
|
||||
}
|
||||
|
||||
// 5. 写入验证码与冷却缓存
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(codeOptions.ExpireMinutes);
|
||||
await cache.SetStringAsync(cacheKey, code, new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpiration = expiresAt
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await cache.SetStringAsync(cooldownKey, "1", new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(codeOptions.CooldownSeconds)
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
logger.LogInformation("发送验证码成功,Phone:{Phone} Scene:{Scene} Tenant:{Tenant}", phone, request.Scene, tenantKey);
|
||||
return new SendVerificationCodeResponse
|
||||
{
|
||||
ExpiresAt = expiresAt,
|
||||
RequestId = smsResult.RequestId
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> VerifyAsync(VerifyVerificationCodeRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 基础校验
|
||||
if (string.IsNullOrWhiteSpace(request.Code))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 读取验证码
|
||||
var codeOptions = codeOptionsMonitor.CurrentValue;
|
||||
var phone = NormalizePhoneNumber(request.PhoneNumber);
|
||||
var tenantKey = tenantProvider.GetCurrentTenantId() == 0 ? "platform" : tenantProvider.GetCurrentTenantId().ToString();
|
||||
var cacheKey = $"{codeOptions.CachePrefix}:{tenantKey}:{request.Scene}:{phone}";
|
||||
|
||||
var cachedCode = await cache.GetStringAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(cachedCode))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 比对成功后清除缓存
|
||||
var success = string.Equals(cachedCode, request.Code, StringComparison.Ordinal);
|
||||
if (success)
|
||||
{
|
||||
await cache.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
private static string ResolveTemplate(string scene, SmsOptions options)
|
||||
{
|
||||
if (options.SceneTemplates.TryGetValue(scene, out var template) && !string.IsNullOrWhiteSpace(template))
|
||||
{
|
||||
return template;
|
||||
}
|
||||
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"未配置场景 {scene} 的短信模板");
|
||||
}
|
||||
|
||||
private static string NormalizePhoneNumber(string phone)
|
||||
{
|
||||
var trimmed = phone.Trim();
|
||||
return trimmed.StartsWith("+", StringComparison.Ordinal) ? trimmed : $"+86{trimmed}";
|
||||
}
|
||||
|
||||
private static string GenerateCode(int length)
|
||||
{
|
||||
var buffer = new byte[length];
|
||||
RandomNumberGenerator.Fill(buffer);
|
||||
var builder = new StringBuilder(length);
|
||||
foreach (var b in buffer)
|
||||
{
|
||||
builder.Append((b % 10).ToString());
|
||||
}
|
||||
|
||||
return builder.ToString()[..length];
|
||||
}
|
||||
|
||||
private async Task EnsureCooldownAsync(string cooldownKey, int cooldownSeconds, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await cache.GetStringAsync(cooldownKey, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrEmpty(existing))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "请求过于频繁,请稍后再试");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user