refactor: AdminApi 剔除租户侧能力

This commit is contained in:
2026-01-29 23:24:44 +00:00
parent 71e5a9dc29
commit 4f8424adb6
139 changed files with 622 additions and 4691 deletions

View File

@@ -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

View File

@@ -13,6 +13,12 @@ public sealed record FileUploadFormRequest
/// </summary>
[Required]
public required IFormFile File { get; init; }
/// <summary>
/// 租户 ID0 表示平台)。
/// </summary>
[Required]
public long? TenantId { get; init; }
/// <summary>
/// 上传类型。
/// </summary>

View File

@@ -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&amp;pageSize=20
/// Header: Authorization: Bearer &lt;JWT&gt;
/// 响应:
/// {
/// "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&amp;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);
}
}

View File

@@ -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 &lt;JWT&gt;
/// 响应:
/// {
/// "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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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&amp;page=1&amp;pageSize=20&amp;sortBy=createdAt&amp;sortDescending=true
/// Header: Authorization: Bearer &lt;JWT&gt;
/// 响应:
/// {
/// "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);
}
}

View File

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

View File

@@ -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;
}
}

View File

@@ -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 Claimtenant_id
app.UseTenantResolution();
app.UseAuthorization();
if (app.Environment.IsDevelopment())
{

View File

@@ -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>

View File

@@ -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;

View File

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

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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;

View File

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

View File

@@ -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,

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -9,6 +9,12 @@ namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// </summary>
public sealed record PublishAnnouncementCommand : IRequest<TenantAnnouncementDto?>
{
/// <summary>
/// 租户 ID0 表示平台公告)。
/// </summary>
[Range(0, long.MaxValue)]
public long TenantId { get; init; }
/// <summary>
/// 公告 ID。
/// </summary>

View File

@@ -9,6 +9,12 @@ namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// </summary>
public sealed record RevokeAnnouncementCommand : IRequest<TenantAnnouncementDto?>
{
/// <summary>
/// 租户 ID0 表示平台公告)。
/// </summary>
[Range(0, long.MaxValue)]
public long TenantId { get; init; }
/// <summary>
/// 公告 ID。
/// </summary>

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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
};
}
}

View File

@@ -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 而不是数据库中的实际总数

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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)
{

View File

@@ -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)
{

View File

@@ -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;
}
}
}

View File

@@ -9,7 +9,7 @@ namespace TakeoutSaaS.Application.App.Tenants.Queries;
public sealed record GetAnnouncementByIdQuery : IRequest<TenantAnnouncementDto?>
{
/// <summary>
/// 租户 ID雪花算法,兼容旧调用,实际以当前租户为准)。
/// 租户 ID0 表示平台公告)。
/// </summary>
public long TenantId { get; init; }

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -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;
}

View File

@@ -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
};
}
}

View File

@@ -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>();
}

View File

@@ -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("登录账号仅允许大小写字母和数字");
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);

View File

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

View File

@@ -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()
{

View File

@@ -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>();

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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>();
}

View File

@@ -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>
/// 目标租户 IDPortal=Tenant 时必填Portal=Admin 时必须为空)。
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 模板编码。
/// </summary>

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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)
{

View File

@@ -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;
}
}

View File

@@ -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);

View File

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

View File

@@ -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();

View File

@@ -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,

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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
{

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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; }
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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 获取 sessionOpenId、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}";
}
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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";
}

View File

@@ -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