refactor: AdminApi 剔除租户侧能力
This commit is contained in:
@@ -4,9 +4,9 @@ using TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Domain.Inventory.Entities;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Domain.Stores.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
/// </summary>
|
||||
public sealed class AdjustInventoryCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
IStoreRepository storeRepository,
|
||||
ILogger<AdjustInventoryCommandHandler> logger)
|
||||
: IRequestHandler<AdjustInventoryCommand, InventoryItemDto>
|
||||
{
|
||||
@@ -23,9 +23,10 @@ public sealed class AdjustInventoryCommandHandler(
|
||||
public async Task<InventoryItemDto> Handle(AdjustInventoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取库存
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
var item = await inventoryRepository.GetForUpdateAsync(null, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
|
||||
// 2. 初始化或校验存在性
|
||||
long tenantId;
|
||||
if (item is null)
|
||||
{
|
||||
if (request.QuantityDelta < 0)
|
||||
@@ -33,6 +34,14 @@ public sealed class AdjustInventoryCommandHandler(
|
||||
throw new BusinessException(ErrorCodes.NotFound, "库存不存在,无法扣减");
|
||||
}
|
||||
|
||||
// 2.1 查询门店以获取 TenantId
|
||||
var store = await storeRepository.FindByIdAsync(request.StoreId, null, cancellationToken);
|
||||
if (store is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "门店不存在");
|
||||
}
|
||||
tenantId = store.TenantId;
|
||||
|
||||
// 初始化库存记录
|
||||
item = new InventoryItem
|
||||
{
|
||||
@@ -46,6 +55,10 @@ public sealed class AdjustInventoryCommandHandler(
|
||||
};
|
||||
await inventoryRepository.AddItemAsync(item, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
tenantId = item.TenantId;
|
||||
}
|
||||
|
||||
// 3. 应用调整
|
||||
var newQuantity = item.QuantityOnHand + request.QuantityDelta;
|
||||
|
||||
@@ -5,7 +5,6 @@ using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
@@ -14,7 +13,6 @@ namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
/// </summary>
|
||||
public sealed class DeductInventoryCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<DeductInventoryCommandHandler> logger)
|
||||
: IRequestHandler<DeductInventoryCommand, InventoryItemDto>
|
||||
{
|
||||
@@ -22,12 +20,12 @@ public sealed class DeductInventoryCommandHandler(
|
||||
public async Task<InventoryItemDto> Handle(DeductInventoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取库存
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
var item = await inventoryRepository.GetForUpdateAsync(null, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "库存不存在");
|
||||
}
|
||||
var tenantId = item.TenantId;
|
||||
|
||||
// 1.1 幂等:若锁记录已扣减/释放则直接返回
|
||||
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
|
||||
|
||||
@@ -2,7 +2,6 @@ using MediatR;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Application.App.Inventory.Queries;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
@@ -10,16 +9,14 @@ namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
/// 库存批次查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetInventoryBatchesQueryHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
IInventoryRepository inventoryRepository)
|
||||
: IRequestHandler<GetInventoryBatchesQuery, IReadOnlyList<InventoryBatchDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<InventoryBatchDto>> Handle(GetInventoryBatchesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取批次
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var batches = await inventoryRepository.GetBatchesAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
var batches = await inventoryRepository.GetBatchesAsync(null, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
// 2. 映射
|
||||
return batches.Select(InventoryMapping.ToDto).ToList();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ using MediatR;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Application.App.Inventory.Queries;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
@@ -10,16 +9,14 @@ namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
/// 查询库存处理器。
|
||||
/// </summary>
|
||||
public sealed class GetInventoryItemQueryHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
IInventoryRepository inventoryRepository)
|
||||
: IRequestHandler<GetInventoryItemQuery, InventoryItemDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<InventoryItemDto?> Handle(GetInventoryItemQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取库存
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var item = await inventoryRepository.FindBySkuAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
var item = await inventoryRepository.FindBySkuAsync(null, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
// 2. 返回 DTO
|
||||
return item is null ? null : InventoryMapping.ToDto(item);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
@@ -14,7 +13,6 @@ namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
/// </summary>
|
||||
public sealed class LockInventoryCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<LockInventoryCommandHandler> logger)
|
||||
: IRequestHandler<LockInventoryCommand, InventoryItemDto>
|
||||
{
|
||||
@@ -22,12 +20,12 @@ public sealed class LockInventoryCommandHandler(
|
||||
public async Task<InventoryItemDto> Handle(LockInventoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取库存
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
var item = await inventoryRepository.GetForUpdateAsync(null, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "库存不存在");
|
||||
}
|
||||
var tenantId = item.TenantId;
|
||||
|
||||
// 1.1 幂等处理
|
||||
var existingLock = await inventoryRepository.FindLockByKeyAsync(tenantId, request.IdempotencyKey, cancellationToken);
|
||||
|
||||
@@ -3,7 +3,6 @@ using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
using TakeoutSaaS.Domain.Inventory.Enums;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
@@ -12,7 +11,6 @@ namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
/// </summary>
|
||||
public sealed class ReleaseExpiredInventoryLocksCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<ReleaseExpiredInventoryLocksCommandHandler> logger)
|
||||
: IRequestHandler<ReleaseExpiredInventoryLocksCommand, int>
|
||||
{
|
||||
@@ -20,9 +18,8 @@ public sealed class ReleaseExpiredInventoryLocksCommandHandler(
|
||||
public async Task<int> Handle(ReleaseExpiredInventoryLocksCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询过期锁
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var now = DateTime.UtcNow;
|
||||
var expiredLocks = await inventoryRepository.FindExpiredLocksAsync(tenantId, now, cancellationToken);
|
||||
var expiredLocks = await inventoryRepository.FindExpiredLocksAsync(null, now, cancellationToken);
|
||||
if (expiredLocks.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
@@ -32,7 +29,7 @@ public sealed class ReleaseExpiredInventoryLocksCommandHandler(
|
||||
var affected = 0;
|
||||
foreach (var lockRecord in expiredLocks)
|
||||
{
|
||||
var item = await inventoryRepository.GetForUpdateAsync(tenantId, lockRecord.StoreId, lockRecord.ProductSkuId, cancellationToken);
|
||||
var item = await inventoryRepository.GetForUpdateAsync(lockRecord.TenantId, lockRecord.StoreId, lockRecord.ProductSkuId, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
continue;
|
||||
|
||||
@@ -5,7 +5,6 @@ using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
@@ -14,7 +13,6 @@ namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
/// </summary>
|
||||
public sealed class ReleaseInventoryCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<ReleaseInventoryCommandHandler> logger)
|
||||
: IRequestHandler<ReleaseInventoryCommand, InventoryItemDto>
|
||||
{
|
||||
@@ -22,12 +20,12 @@ public sealed class ReleaseInventoryCommandHandler(
|
||||
public async Task<InventoryItemDto> Handle(ReleaseInventoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取库存
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
var item = await inventoryRepository.GetForUpdateAsync(null, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "库存不存在");
|
||||
}
|
||||
var tenantId = item.TenantId;
|
||||
|
||||
// 1.1 幂等处理:若提供键且锁记录不存在,直接视为已释放
|
||||
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
|
||||
|
||||
@@ -6,7 +6,6 @@ using TakeoutSaaS.Domain.Inventory.Entities;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
|
||||
@@ -15,7 +14,6 @@ namespace TakeoutSaaS.Application.App.Inventory.Handlers;
|
||||
/// </summary>
|
||||
public sealed class UpsertInventoryBatchCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<UpsertInventoryBatchCommandHandler> logger)
|
||||
: IRequestHandler<UpsertInventoryBatchCommand, InventoryBatchDto>
|
||||
{
|
||||
@@ -23,14 +21,21 @@ public sealed class UpsertInventoryBatchCommandHandler(
|
||||
public async Task<InventoryBatchDto> Handle(UpsertInventoryBatchCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取批次
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var batch = await inventoryRepository.GetBatchForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, request.BatchNumber, cancellationToken);
|
||||
var batch = await inventoryRepository.GetBatchForUpdateAsync(null, request.StoreId, request.ProductSkuId, request.BatchNumber, cancellationToken);
|
||||
|
||||
// 2. 创建或更新
|
||||
if (batch is null)
|
||||
{
|
||||
// 2.1 查询库存以获取 TenantId
|
||||
var item = await inventoryRepository.FindBySkuAsync(null, request.StoreId, request.ProductSkuId, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "库存不存在,无法新增批次");
|
||||
}
|
||||
|
||||
batch = new InventoryBatch
|
||||
{
|
||||
TenantId = tenantId,
|
||||
TenantId = item.TenantId,
|
||||
StoreId = request.StoreId,
|
||||
ProductSkuId = request.ProductSkuId,
|
||||
BatchNumber = request.BatchNumber,
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 生成租户主管理员重置链接令牌命令(平台超级管理员使用)。
|
||||
/// </summary>
|
||||
public sealed record CreateTenantAdminResetLinkTokenCommand : IRequest<string>
|
||||
{
|
||||
/// <summary>
|
||||
/// 目标租户 ID。
|
||||
/// </summary>
|
||||
public required long TenantId { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 伪装登录租户命令(平台超级管理员使用)。
|
||||
/// </summary>
|
||||
public sealed record ImpersonateTenantCommand : IRequest<TokenResponse>
|
||||
{
|
||||
/// <summary>
|
||||
/// 目标租户 ID。
|
||||
/// </summary>
|
||||
public required long TenantId { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 标记公告已读命令。
|
||||
/// </summary>
|
||||
public sealed record MarkAnnouncementAsReadCommand : IRequest<TenantAnnouncementDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法,兼容旧调用,实际以当前租户为准)。
|
||||
/// </summary>
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 公告 ID。
|
||||
/// </summary>
|
||||
[Range(1, long.MaxValue)]
|
||||
public long AnnouncementId { get; init; }
|
||||
}
|
||||
@@ -9,6 +9,12 @@ namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
/// </summary>
|
||||
public sealed record PublishAnnouncementCommand : IRequest<TenantAnnouncementDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(0 表示平台公告)。
|
||||
/// </summary>
|
||||
[Range(0, long.MaxValue)]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 公告 ID。
|
||||
/// </summary>
|
||||
|
||||
@@ -9,6 +9,12 @@ namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
/// </summary>
|
||||
public sealed record RevokeAnnouncementCommand : IRequest<TenantAnnouncementDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(0 表示平台公告)。
|
||||
/// </summary>
|
||||
[Range(0, long.MaxValue)]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 公告 ID。
|
||||
/// </summary>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 自助注册租户命令。
|
||||
/// </summary>
|
||||
public sealed record SelfRegisterTenantCommand : IRequest<SelfRegisterResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始管理员账号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(64)]
|
||||
[RegularExpression("^[A-Za-z0-9]+$", ErrorMessage = "登录账号仅允许大小写字母和数字")]
|
||||
public string AdminAccount { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 初始管理员展示名称。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? AdminDisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 初始管理员邮箱。
|
||||
/// </summary>
|
||||
[EmailAddress]
|
||||
[StringLength(128)]
|
||||
public string? AdminEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 初始管理员手机号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(32)]
|
||||
public string AdminPhone { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 初始管理员登录密码(前端自定义)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(128, MinimumLength = 8)]
|
||||
public string AdminPassword { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 自助注册结果 DTO。
|
||||
/// </summary>
|
||||
public sealed class SelfRegisterResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 初始状态。
|
||||
/// </summary>
|
||||
public TenantStatus Status { get; init; } = TenantStatus.PendingReview;
|
||||
|
||||
/// <summary>
|
||||
/// 当前实名状态。
|
||||
/// </summary>
|
||||
public TenantVerificationStatus VerificationStatus { get; init; } = TenantVerificationStatus.Draft;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅开始时间。
|
||||
/// </summary>
|
||||
public DateTime? EffectiveFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订阅到期时间。
|
||||
/// </summary>
|
||||
public DateTime? EffectiveTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 初始管理员账号。
|
||||
/// </summary>
|
||||
public string AdminAccount { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 租户入住进度 DTO。
|
||||
/// </summary>
|
||||
public sealed class TenantProgressDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 当前租户状态。
|
||||
/// </summary>
|
||||
public TenantStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 实名审核状态。
|
||||
/// </summary>
|
||||
public TenantVerificationStatus VerificationStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前订阅开始时间。
|
||||
/// </summary>
|
||||
public DateTime? EffectiveFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前订阅到期时间。
|
||||
/// </summary>
|
||||
public DateTime? EffectiveTo { get; init; }
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 生成租户主管理员重置链接令牌处理器(平台超级管理员使用)。
|
||||
/// </summary>
|
||||
public sealed class CreateTenantAdminResetLinkTokenCommandHandler(
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IAdminPasswordResetTokenStore tokenStore)
|
||||
: IRequestHandler<CreateTenantAdminResetLinkTokenCommand, string>
|
||||
{
|
||||
private const long PlatformRootTenantId = 1000000000001;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> Handle(CreateTenantAdminResetLinkTokenCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验仅允许平台超级管理员执行
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (currentTenantId != PlatformRootTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅平台超级管理员可生成重置链接");
|
||||
}
|
||||
|
||||
// 2. 校验租户存在且存在主管理员
|
||||
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
|
||||
|
||||
// 2.1 若缺少主管理员则自动回填(兼容历史数据)
|
||||
if (!tenant.PrimaryOwnerUserId.HasValue || tenant.PrimaryOwnerUserId.Value == 0)
|
||||
{
|
||||
var originalContextForFix = tenantContextAccessor.Current;
|
||||
tenantContextAccessor.Current = new TenantContext(tenant.Id, tenant.Code, "admin:reset-link:fix-owner");
|
||||
try
|
||||
{
|
||||
var users = await identityUserRepository.SearchAsync(tenant.Id, keyword: null, cancellationToken);
|
||||
var ownerCandidate = users.OrderBy(x => x.CreatedAt).FirstOrDefault();
|
||||
if (ownerCandidate == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "该租户未配置主管理员账号,且未找到可用管理员账号");
|
||||
}
|
||||
|
||||
tenant.PrimaryOwnerUserId = ownerCandidate.Id;
|
||||
await tenantRepository.UpdateTenantAsync(tenant, cancellationToken);
|
||||
await tenantRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContextAccessor.Current = originalContextForFix;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 签发一次性重置令牌(默认 24 小时有效)
|
||||
var token = await tokenStore.IssueAsync(tenant.PrimaryOwnerUserId.Value, DateTime.UtcNow.AddHours(24), cancellationToken);
|
||||
|
||||
// 4. 写入审计日志
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? $"user:{currentUserAccessor.UserId}"
|
||||
: operatorProfile.DisplayName;
|
||||
|
||||
var auditLog = new TenantAuditLog
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
Action = TenantAuditAction.AdminResetLinkIssued,
|
||||
Title = "生成重置链接",
|
||||
Description = $"操作者:{operatorName},目标用户ID:{tenant.PrimaryOwnerUserId.Value}",
|
||||
OperatorId = currentUserAccessor.UserId,
|
||||
OperatorName = operatorName,
|
||||
PreviousStatus = tenant.Status,
|
||||
CurrentStatus = tenant.Status
|
||||
};
|
||||
await tenantRepository.AddAuditLogAsync(auditLog, cancellationToken);
|
||||
await tenantRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 5. 返回令牌
|
||||
return token;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
@@ -25,11 +24,11 @@ public sealed class CreateTenantManuallyCommandHandler(
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantPackageRepository tenantPackageRepository,
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IPasswordHasher<IdentityUser> passwordHasher,
|
||||
IIdGenerator idGenerator,
|
||||
IMediator mediator,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
ILogger<CreateTenantManuallyCommandHandler> logger)
|
||||
: IRequestHandler<CreateTenantManuallyCommand, TenantDetailDto>
|
||||
@@ -208,56 +207,44 @@ public sealed class CreateTenantManuallyCommandHandler(
|
||||
await tenantRepository.UpsertVerificationProfileAsync(verification, cancellationToken);
|
||||
await tenantRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 12. 临时切换租户上下文,保证身份与权限写入正确
|
||||
var previousContext = tenantContextAccessor.Current;
|
||||
tenantContextAccessor.Current = new TenantContext(tenant.Id, tenant.Code, "manual-create");
|
||||
try
|
||||
// 12. 创建租户管理员账号(Portal=Tenant)
|
||||
var adminUser = new IdentityUser
|
||||
{
|
||||
// 13. 创建租户管理员账号
|
||||
var adminUser = new IdentityUser
|
||||
{
|
||||
Portal = PortalType.Tenant,
|
||||
TenantId = tenant.Id,
|
||||
Account = normalizedAccount,
|
||||
DisplayName = request.AdminDisplayName.Trim(),
|
||||
PasswordHash = string.Empty,
|
||||
Phone = string.IsNullOrWhiteSpace(request.ContactPhone) ? null : request.ContactPhone.Trim(),
|
||||
Email = string.IsNullOrWhiteSpace(request.ContactEmail) ? null : request.ContactEmail.Trim(),
|
||||
MerchantId = request.AdminMerchantId,
|
||||
Avatar = request.AdminAvatar
|
||||
};
|
||||
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, request.AdminPassword);
|
||||
await identityUserRepository.AddAsync(adminUser, cancellationToken);
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
Portal = PortalType.Tenant,
|
||||
TenantId = tenant.Id,
|
||||
Account = normalizedAccount,
|
||||
DisplayName = request.AdminDisplayName.Trim(),
|
||||
PasswordHash = string.Empty,
|
||||
Phone = string.IsNullOrWhiteSpace(request.ContactPhone) ? null : request.ContactPhone.Trim(),
|
||||
Email = string.IsNullOrWhiteSpace(request.ContactEmail) ? null : request.ContactEmail.Trim(),
|
||||
MerchantId = request.AdminMerchantId,
|
||||
Avatar = request.AdminAvatar
|
||||
};
|
||||
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, request.AdminPassword);
|
||||
await identityUserRepository.AddAsync(adminUser, cancellationToken);
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 14. 初始化租户管理员角色模板并绑定角色
|
||||
await mediator.Send(new InitializeRoleTemplatesCommand
|
||||
{
|
||||
TemplateCodes = new[] { "tenant-admin" }
|
||||
}, cancellationToken);
|
||||
|
||||
var tenantAdminRole = await roleRepository.FindByCodeAsync(PortalType.Tenant, tenant.Id, "tenant-admin", cancellationToken);
|
||||
if (tenantAdminRole != null)
|
||||
{
|
||||
await mediator.Send(new AssignUserRolesCommand
|
||||
{
|
||||
UserId = adminUser.Id,
|
||||
RoleIds = new[] { tenantAdminRole.Id }
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
// 15. 回写租户所有者账号
|
||||
tenant.PrimaryOwnerUserId = adminUser.Id;
|
||||
await tenantRepository.UpdateTenantAsync(tenant, cancellationToken);
|
||||
await tenantRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
// 13. 初始化租户管理员角色并绑定到用户(不依赖租户上下文)
|
||||
await mediator.Send(new CopyRoleTemplateCommand
|
||||
{
|
||||
// 16. 恢复上下文
|
||||
tenantContextAccessor.Current = previousContext;
|
||||
Portal = PortalType.Tenant,
|
||||
TenantId = tenant.Id,
|
||||
TemplateCode = "tenant-admin"
|
||||
}, cancellationToken);
|
||||
|
||||
var tenantAdminRole = await roleRepository.FindByCodeAsync(PortalType.Tenant, tenant.Id, "tenant-admin", cancellationToken);
|
||||
if (tenantAdminRole != null)
|
||||
{
|
||||
await userRoleRepository.ReplaceUserRolesAsync(PortalType.Tenant, tenant.Id, adminUser.Id, new[] { tenantAdminRole.Id }, cancellationToken);
|
||||
await userRoleRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// 17. 返回创建结果
|
||||
// 14. 回写租户所有者账号
|
||||
tenant.PrimaryOwnerUserId = adminUser.Id;
|
||||
await tenantRepository.UpdateTenantAsync(tenant, cancellationToken);
|
||||
await tenantRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 15. 返回创建结果
|
||||
logger.LogInformation("已后台手动创建租户 {TenantCode}", tenant.Code);
|
||||
|
||||
return new TenantDetailDto
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
using TakeoutSaaS.Application.App.Tenants.Targeting;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
@@ -13,12 +9,7 @@ namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
/// 公告详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetAnnouncementByIdQueryHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ITenantAnnouncementReadRepository readRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IAdminAuthService? adminAuthService = null,
|
||||
IMiniAuthService? miniAuthService = null)
|
||||
ITenantAnnouncementRepository announcementRepository)
|
||||
: IRequestHandler<GetAnnouncementByIdQuery, TenantAnnouncementDto?>
|
||||
{
|
||||
/// <summary>
|
||||
@@ -29,43 +20,14 @@ public sealed class GetAnnouncementByIdQueryHandler(
|
||||
/// <returns>公告 DTO 或 null。</returns>
|
||||
public async Task<TenantAnnouncementDto?> Handle(GetAnnouncementByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
|
||||
// 1. 查询公告主体(含平台公告)
|
||||
var announcement = await announcementRepository.FindByIdInScopeAsync(tenantId, request.AnnouncementId, cancellationToken);
|
||||
// 1. 查询公告主体
|
||||
var announcement = await announcementRepository.FindByIdAsync(request.TenantId, request.AnnouncementId, cancellationToken);
|
||||
if (announcement == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 目标受众过滤
|
||||
var targetContext = await AnnouncementTargetContextFactory.BuildAsync(
|
||||
tenantProvider,
|
||||
currentUserAccessor,
|
||||
adminAuthService,
|
||||
miniAuthService,
|
||||
cancellationToken);
|
||||
|
||||
if (!TargetTypeFilter.IsMatch(announcement, targetContext))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 优先查用户级已读
|
||||
var userId = targetContext.UserId;
|
||||
var reads = await readRepository.GetByAnnouncementAsync(
|
||||
tenantId,
|
||||
new[] { announcement.Id },
|
||||
userId == 0 ? null : userId,
|
||||
cancellationToken);
|
||||
|
||||
if (reads.Count == 0)
|
||||
{
|
||||
var tenantReads = await readRepository.GetByAnnouncementAsync(tenantId, new[] { announcement.Id }, null, cancellationToken);
|
||||
reads = tenantReads;
|
||||
}
|
||||
|
||||
var readRecord = reads.FirstOrDefault();
|
||||
return announcement.ToDto(readRecord != null, readRecord?.ReadAt);
|
||||
// 2. (空行后) 映射 DTO(管理端不返回已读信息)
|
||||
return announcement.ToDto(false, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 公共场景分页查询启用套餐处理器。
|
||||
/// </summary>
|
||||
public sealed class GetPublicTenantPackagesQueryHandler(ITenantPackageRepository packageRepository)
|
||||
: IRequestHandler<GetPublicTenantPackagesQuery, PagedResult<TenantPackageDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<TenantPackageDto>> Handle(GetPublicTenantPackagesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 仅查询公共可选购套餐(已发布 + 对外可见 + 允许新购 + 启用)
|
||||
var packages = await packageRepository.SearchPublicPurchasableAsync(cancellationToken);
|
||||
// 2. 规范化分页参数
|
||||
var pageIndex = request.Page <= 0 ? 1 : request.Page;
|
||||
var size = request.PageSize <= 0 ? 20 : request.PageSize;
|
||||
// 3. 执行排序、分页与映射
|
||||
var ordered = packages
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenByDescending(x => x.CreatedAt)
|
||||
.ToList();
|
||||
var items = ordered
|
||||
.Skip((pageIndex - 1) * size)
|
||||
.Take(size)
|
||||
.Select(x => x.ToDto())
|
||||
.ToList();
|
||||
// 4. 返回分页结果
|
||||
return new PagedResult<TenantPackageDto>(items, pageIndex, size, ordered.Count);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 租户入住进度查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetTenantProgressQueryHandler(ITenantRepository tenantRepository)
|
||||
: IRequestHandler<GetTenantProgressQuery, TenantProgressDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<TenantProgressDto> Handle(GetTenantProgressQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询租户
|
||||
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
|
||||
|
||||
// 2. 查询订阅与实名
|
||||
var subscription = await tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken);
|
||||
var verification = await tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken);
|
||||
|
||||
// 3. 组装进度信息
|
||||
return new TenantProgressDto
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
Code = tenant.Code,
|
||||
Status = tenant.Status,
|
||||
VerificationStatus = verification?.Status ?? TenantVerificationStatus.Draft,
|
||||
EffectiveFrom = subscription?.EffectiveFrom ?? tenant.EffectiveFrom,
|
||||
EffectiveTo = subscription?.EffectiveTo ?? tenant.EffectiveTo
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
using TakeoutSaaS.Application.App.Tenants.Targeting;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
@@ -14,12 +10,7 @@ namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
/// 公告分页查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetTenantsAnnouncementsQueryHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ITenantAnnouncementReadRepository announcementReadRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IAdminAuthService? adminAuthService = null,
|
||||
IMiniAuthService? miniAuthService = null)
|
||||
ITenantAnnouncementRepository announcementRepository)
|
||||
: IRequestHandler<GetTenantsAnnouncementsQuery, PagedResult<TenantAnnouncementDto>>
|
||||
{
|
||||
/// <summary>
|
||||
@@ -30,7 +21,7 @@ public sealed class GetTenantsAnnouncementsQueryHandler(
|
||||
/// <returns>分页结果。</returns>
|
||||
public async Task<PagedResult<TenantAnnouncementDto>> Handle(GetTenantsAnnouncementsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var tenantId = request.TenantId;
|
||||
var effectiveAt = request.OnlyEffective == true ? DateTime.UtcNow : (DateTime?)null;
|
||||
|
||||
// 计算分页参数
|
||||
@@ -64,62 +55,20 @@ public sealed class GetTenantsAnnouncementsQueryHandler(
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// 3. 目标受众过滤(在内存中,但数据量已大幅减少)
|
||||
var targetContext = await AnnouncementTargetContextFactory.BuildAsync(
|
||||
tenantProvider,
|
||||
currentUserAccessor,
|
||||
adminAuthService,
|
||||
miniAuthService,
|
||||
cancellationToken);
|
||||
|
||||
// 3. 按租户隔离(仅返回 request.TenantId 对应的公告)
|
||||
var filtered = announcements
|
||||
.Where(a => TargetTypeFilter.IsMatch(a, targetContext))
|
||||
.Where(x => x.TenantId == tenantId)
|
||||
.ToList();
|
||||
|
||||
// 注意:由于目标受众过滤可能移除记录,filtered.Count 可能小于请求的 size
|
||||
// 这是可接受的,因为精确计算总数代价高昂
|
||||
|
||||
// 4. 分页(数据已在数据库层排序,这里只需 Skip/Take)
|
||||
var pageItems = filtered
|
||||
.Skip((page - 1) * size)
|
||||
.Take(size)
|
||||
.ToList();
|
||||
|
||||
// 5. 构建已读映射
|
||||
var announcementIds = pageItems.Select(x => x.Id).ToArray();
|
||||
var userId = targetContext.UserId;
|
||||
|
||||
var readMap = new Dictionary<long, (bool isRead, DateTime? readAt)>();
|
||||
if (announcementIds.Length > 0)
|
||||
{
|
||||
var reads = new List<Domain.Tenants.Entities.TenantAnnouncementRead>();
|
||||
if (userId != 0)
|
||||
{
|
||||
var userReads = await announcementReadRepository.GetByAnnouncementAsync(tenantId, announcementIds, userId, cancellationToken);
|
||||
reads.AddRange(userReads);
|
||||
}
|
||||
|
||||
var tenantReads = await announcementReadRepository.GetByAnnouncementAsync(tenantId, announcementIds, null, cancellationToken);
|
||||
reads.AddRange(tenantReads);
|
||||
|
||||
foreach (var read in reads.OrderByDescending(x => x.ReadAt))
|
||||
{
|
||||
if (readMap.ContainsKey(read.AnnouncementId) && read.UserId.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
readMap[read.AnnouncementId] = (true, read.ReadAt);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 映射 DTO 并带上已读状态
|
||||
// 5. 映射 DTO
|
||||
var items = pageItems
|
||||
.Select(a =>
|
||||
{
|
||||
readMap.TryGetValue(a.Id, out var read);
|
||||
return a.ToDto(read.isRead, read.readAt);
|
||||
})
|
||||
.Select(a => a.ToDto(false, null))
|
||||
.ToList();
|
||||
|
||||
// 注意:由于我们使用了估算的 limit,总数是 filtered.Count 而不是数据库中的实际总数
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
using TakeoutSaaS.Application.App.Tenants.Targeting;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 未读公告查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetUnreadAnnouncementsQueryHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IAdminAuthService? adminAuthService = null,
|
||||
IMiniAuthService? miniAuthService = null)
|
||||
: IRequestHandler<GetUnreadAnnouncementsQuery, PagedResult<TenantAnnouncementDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<TenantAnnouncementDto>> Handle(GetUnreadAnnouncementsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var userId = currentUserAccessor?.UserId ?? 0;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// 1. 查询未读公告(已发布/启用/有效期内)
|
||||
var announcements = await announcementRepository.SearchUnreadAsync(
|
||||
tenantId,
|
||||
userId == 0 ? null : userId,
|
||||
AnnouncementStatus.Published,
|
||||
true,
|
||||
now,
|
||||
cancellationToken);
|
||||
|
||||
announcements = announcements
|
||||
.Where(x => x.ScheduledPublishAt == null || x.ScheduledPublishAt <= now)
|
||||
.ToList();
|
||||
|
||||
// 2. 目标受众过滤
|
||||
var targetContext = await AnnouncementTargetContextFactory.BuildAsync(
|
||||
tenantProvider,
|
||||
currentUserAccessor,
|
||||
adminAuthService,
|
||||
miniAuthService,
|
||||
cancellationToken);
|
||||
|
||||
var filtered = announcements
|
||||
.Where(a => TargetTypeFilter.IsMatch(a, targetContext))
|
||||
.ToList();
|
||||
|
||||
// 3. 排序与分页
|
||||
var ordered = filtered
|
||||
.OrderByDescending(x => x.Priority)
|
||||
.ThenByDescending(x => x.EffectiveFrom)
|
||||
.ToList();
|
||||
|
||||
var page = request.Page <= 0 ? 1 : request.Page;
|
||||
var size = request.PageSize <= 0 ? 20 : request.PageSize;
|
||||
var pageItems = ordered
|
||||
.Skip((page - 1) * size)
|
||||
.Take(size)
|
||||
.ToList();
|
||||
|
||||
var items = pageItems
|
||||
.Select(x => x.ToDto(false, null))
|
||||
.ToList();
|
||||
|
||||
return new PagedResult<TenantAnnouncementDto>(items, page, size, ordered.Count);
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 伪装登录租户处理器(平台超级管理员使用)。
|
||||
/// </summary>
|
||||
public sealed class ImpersonateTenantCommandHandler(
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IJwtTokenService jwtTokenService)
|
||||
: IRequestHandler<ImpersonateTenantCommand, TokenResponse>
|
||||
{
|
||||
private const long PlatformRootTenantId = 1000000000001;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TokenResponse> Handle(ImpersonateTenantCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验仅允许平台超级管理员执行
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (currentTenantId != PlatformRootTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅平台超级管理员可执行伪装登录");
|
||||
}
|
||||
|
||||
// 2. 读取操作者信息(在平台租户上下文内)
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? $"user:{currentUserAccessor.UserId}"
|
||||
: operatorProfile.DisplayName;
|
||||
|
||||
// 2. 校验租户存在且存在主管理员
|
||||
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
|
||||
|
||||
// 2.1 若缺少主管理员则自动回填(兼容历史数据)
|
||||
if (!tenant.PrimaryOwnerUserId.HasValue || tenant.PrimaryOwnerUserId.Value == 0)
|
||||
{
|
||||
var originalContextForFix = tenantContextAccessor.Current;
|
||||
tenantContextAccessor.Current = new TenantContext(tenant.Id, tenant.Code, "admin:impersonate:fix-owner");
|
||||
try
|
||||
{
|
||||
var users = await identityUserRepository.SearchAsync(tenant.Id, keyword: null, cancellationToken);
|
||||
var ownerCandidate = users.OrderBy(x => x.CreatedAt).FirstOrDefault();
|
||||
if (ownerCandidate == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "该租户未配置主管理员账号,且未找到可用管理员账号");
|
||||
}
|
||||
|
||||
tenant.PrimaryOwnerUserId = ownerCandidate.Id;
|
||||
await tenantRepository.UpdateTenantAsync(tenant, cancellationToken);
|
||||
await tenantRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContextAccessor.Current = originalContextForFix;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 进入目标租户上下文以读取租户内用户(避免多租户查询过滤导致找不到用户)
|
||||
var originalTenantContext = tenantContextAccessor.Current;
|
||||
tenantContextAccessor.Current = new TenantContext(tenant.Id, null, "admin:impersonate");
|
||||
try
|
||||
{
|
||||
// 4. 为租户主管理员签发令牌
|
||||
var targetProfile = await adminAuthService.GetProfileAsync(tenant.PrimaryOwnerUserId.Value, cancellationToken);
|
||||
var token = await jwtTokenService.CreateTokensAsync(targetProfile, false, cancellationToken);
|
||||
|
||||
// 5. 恢复租户上下文后写入审计日志
|
||||
tenantContextAccessor.Current = originalTenantContext;
|
||||
var auditLog = new TenantAuditLog
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
Action = TenantAuditAction.ImpersonatedLogin,
|
||||
Title = "伪装登录",
|
||||
Description = $"操作者:{operatorName},目标账号:{targetProfile.Account}",
|
||||
OperatorId = currentUserAccessor.UserId,
|
||||
OperatorName = operatorName,
|
||||
PreviousStatus = tenant.Status,
|
||||
CurrentStatus = tenant.Status
|
||||
};
|
||||
await tenantRepository.AddAuditLogAsync(auditLog, cancellationToken);
|
||||
await tenantRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 6. 返回令牌
|
||||
return token;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 7. 确保恢复租户上下文
|
||||
tenantContextAccessor.Current = originalTenantContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Targeting;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 标记公告已读处理器。
|
||||
/// </summary>
|
||||
public sealed class MarkAnnouncementAsReadCommandHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ITenantAnnouncementReadRepository readRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IAdminAuthService? adminAuthService = null,
|
||||
IMiniAuthService? miniAuthService = null)
|
||||
: IRequestHandler<MarkAnnouncementAsReadCommand, TenantAnnouncementDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 标记公告已读。
|
||||
/// </summary>
|
||||
/// <param name="request">标记命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>公告 DTO 或 null。</returns>
|
||||
public async Task<TenantAnnouncementDto?> Handle(MarkAnnouncementAsReadCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
|
||||
// 1. 查询公告(含平台公告)
|
||||
var announcement = await announcementRepository.FindByIdInScopeAsync(tenantId, request.AnnouncementId, cancellationToken);
|
||||
if (announcement == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 仅允许已发布且在有效期内的公告标记已读
|
||||
var now = DateTime.UtcNow;
|
||||
if (announcement.Status != AnnouncementStatus.Published)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (announcement.EffectiveFrom > now || (announcement.EffectiveTo.HasValue && announcement.EffectiveTo.Value < now))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (announcement.ScheduledPublishAt.HasValue && announcement.ScheduledPublishAt.Value > now)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 目标受众过滤
|
||||
var targetContext = await AnnouncementTargetContextFactory.BuildAsync(
|
||||
tenantProvider,
|
||||
currentUserAccessor,
|
||||
adminAuthService,
|
||||
miniAuthService,
|
||||
cancellationToken);
|
||||
|
||||
if (!TargetTypeFilter.IsMatch(announcement, targetContext))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4. 确定用户标识
|
||||
var userId = targetContext.UserId == 0 ? (long?)null : targetContext.UserId;
|
||||
var existing = await readRepository.FindAsync(tenantId, announcement.Id, userId, cancellationToken);
|
||||
|
||||
if (existing == null && userId.HasValue)
|
||||
{
|
||||
existing = await readRepository.FindAsync(tenantId, announcement.Id, null, cancellationToken);
|
||||
}
|
||||
|
||||
// 5. 如未读则写入已读记录
|
||||
if (existing == null)
|
||||
{
|
||||
var record = new TenantAnnouncementRead
|
||||
{
|
||||
TenantId = tenantId,
|
||||
AnnouncementId = announcement.Id,
|
||||
UserId = userId,
|
||||
ReadAt = now
|
||||
};
|
||||
|
||||
await readRepository.AddAsync(record, cancellationToken);
|
||||
await readRepository.SaveChangesAsync(cancellationToken);
|
||||
existing = record;
|
||||
}
|
||||
|
||||
return announcement.ToDto(true, existing.ReadAt);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ using TakeoutSaaS.Domain.Tenants.Events;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
@@ -16,7 +15,6 @@ namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
/// </summary>
|
||||
public sealed class PublishAnnouncementCommandHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
IEventPublisher eventPublisher)
|
||||
: IRequestHandler<PublishAnnouncementCommand, TenantAnnouncementDto?>
|
||||
{
|
||||
@@ -29,7 +27,7 @@ public sealed class PublishAnnouncementCommandHandler(
|
||||
}
|
||||
|
||||
// 1. 查询公告
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var tenantId = request.TenantId;
|
||||
var announcement = await announcementRepository.FindByIdAsync(tenantId, request.AnnouncementId, cancellationToken);
|
||||
if (announcement == null)
|
||||
{
|
||||
|
||||
@@ -7,7 +7,6 @@ using TakeoutSaaS.Domain.Tenants.Events;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
@@ -16,7 +15,6 @@ namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
/// </summary>
|
||||
public sealed class RevokeAnnouncementCommandHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
IEventPublisher eventPublisher)
|
||||
: IRequestHandler<RevokeAnnouncementCommand, TenantAnnouncementDto?>
|
||||
{
|
||||
@@ -29,7 +27,7 @@ public sealed class RevokeAnnouncementCommandHandler(
|
||||
}
|
||||
|
||||
// 1. 查询公告
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var tenantId = request.TenantId;
|
||||
var announcement = await announcementRepository.FindByIdAsync(tenantId, request.AnnouncementId, cancellationToken);
|
||||
if (announcement == null)
|
||||
{
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 自助注册租户处理器。
|
||||
/// </summary>
|
||||
public sealed class SelfRegisterTenantCommandHandler(
|
||||
ITenantRepository tenantRepository,
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IPasswordHasher<IdentityUser> passwordHasher,
|
||||
IIdGenerator idGenerator,
|
||||
IMediator mediator,
|
||||
ITenantContextAccessor tenantContextAccessor)
|
||||
: IRequestHandler<SelfRegisterTenantCommand, SelfRegisterResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<SelfRegisterResultDto> Handle(SelfRegisterTenantCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 唯一性校验
|
||||
var normalizedAccount = request.AdminAccount.Trim();
|
||||
if (await identityUserRepository.ExistsByAccountAsync(normalizedAccount, cancellationToken))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, $"账号 {normalizedAccount} 已存在");
|
||||
}
|
||||
|
||||
// 1.2 校验手机号唯一性
|
||||
var normalizedPhone = request.AdminPhone.Trim();
|
||||
if (await tenantRepository.ExistsByContactPhoneAsync(normalizedPhone, cancellationToken))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, $"手机号 {normalizedPhone} 已注册");
|
||||
}
|
||||
|
||||
// 2. 生成租户标识与编码
|
||||
var tenantId = idGenerator.NextId();
|
||||
var tenantCode = $"t{tenantId}";
|
||||
|
||||
// 3. 构建租户(无订阅,待审核)
|
||||
var tenant = new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Code = tenantCode,
|
||||
Name = normalizedAccount,
|
||||
ShortName = string.IsNullOrWhiteSpace(request.AdminDisplayName) ? normalizedAccount : request.AdminDisplayName!.Trim(),
|
||||
ContactName = string.IsNullOrWhiteSpace(request.AdminDisplayName) ? normalizedAccount : request.AdminDisplayName!.Trim(),
|
||||
ContactPhone = normalizedPhone,
|
||||
ContactEmail = request.AdminEmail,
|
||||
Status = TenantStatus.PendingReview,
|
||||
EffectiveFrom = null,
|
||||
EffectiveTo = null
|
||||
};
|
||||
|
||||
// 4. 写入审计日志
|
||||
var auditLog = new TenantAuditLog
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
Action = TenantAuditAction.RegistrationSubmitted,
|
||||
Title = "自助注册",
|
||||
Description = "自助注册提交,等待补充资料与审核"
|
||||
};
|
||||
|
||||
// 5. 持久化租户与审计
|
||||
await tenantRepository.AddTenantAsync(tenant, cancellationToken);
|
||||
await tenantRepository.AddAuditLogAsync(auditLog, cancellationToken);
|
||||
await tenantRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 6. 临时切换租户上下文,保证身份与权限写入正确
|
||||
var previousContext = tenantContextAccessor.Current;
|
||||
tenantContextAccessor.Current = new TenantContext(tenant.Id, tenant.Code, "self-register");
|
||||
try
|
||||
{
|
||||
// 7. 使用用户自设密码创建管理员
|
||||
var adminUser = new IdentityUser
|
||||
{
|
||||
Portal = PortalType.Tenant,
|
||||
TenantId = tenant.Id,
|
||||
Account = normalizedAccount,
|
||||
DisplayName = string.IsNullOrWhiteSpace(request.AdminDisplayName) ? normalizedAccount : request.AdminDisplayName!.Trim(),
|
||||
PasswordHash = string.Empty,
|
||||
Phone = normalizedPhone,
|
||||
Email = string.IsNullOrWhiteSpace(request.AdminEmail) ? null : request.AdminEmail.Trim()
|
||||
};
|
||||
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, request.AdminPassword);
|
||||
await identityUserRepository.AddAsync(adminUser, cancellationToken);
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 7.1 回填主管理员标识,确保后续伪装登录/重置管理员等能力可用
|
||||
tenant.PrimaryOwnerUserId = adminUser.Id;
|
||||
await tenantRepository.UpdateTenantAsync(tenant, cancellationToken);
|
||||
await tenantRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 8. 初始化租户管理员角色模板
|
||||
await mediator.Send(new InitializeRoleTemplatesCommand
|
||||
{
|
||||
TemplateCodes = new[] { "tenant-admin" }
|
||||
}, cancellationToken);
|
||||
|
||||
// 9. 绑定租户管理员角色
|
||||
var tenantAdminRole = await roleRepository.FindByCodeAsync(PortalType.Tenant, tenant.Id, "tenant-admin", cancellationToken);
|
||||
if (tenantAdminRole != null)
|
||||
{
|
||||
await mediator.Send(new AssignUserRolesCommand
|
||||
{
|
||||
UserId = adminUser.Id,
|
||||
RoleIds = new[] { tenantAdminRole.Id }
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
// 10. 返回注册结果
|
||||
return new SelfRegisterResultDto
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
Code = tenant.Code,
|
||||
Status = tenant.Status,
|
||||
VerificationStatus = TenantVerificationStatus.Draft,
|
||||
EffectiveFrom = tenant.EffectiveFrom,
|
||||
EffectiveTo = tenant.EffectiveTo,
|
||||
AdminAccount = adminUser.Account
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 11. 恢复上下文
|
||||
tenantContextAccessor.Current = previousContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ namespace TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
public sealed record GetAnnouncementByIdQuery : IRequest<TenantAnnouncementDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法,兼容旧调用,实际以当前租户为准)。
|
||||
/// 租户 ID(0 表示平台公告)。
|
||||
/// </summary>
|
||||
public long TenantId { get; init; }
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 公共场景分页查询启用套餐。
|
||||
/// </summary>
|
||||
public sealed record GetPublicTenantPackagesQuery : IRequest<PagedResult<TenantPackageDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 租户入住进度查询。
|
||||
/// </summary>
|
||||
public sealed record GetTenantProgressQuery : IRequest<TenantProgressDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public long TenantId { get; init; }
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询未读公告。
|
||||
/// </summary>
|
||||
public sealed record GetUnreadAnnouncementsQuery : IRequest<PagedResult<TenantAnnouncementDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码(从 1 开始)。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Targeting;
|
||||
|
||||
/// <summary>
|
||||
/// 目标受众上下文构建器。
|
||||
/// </summary>
|
||||
internal static class AnnouncementTargetContextFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// 构建当前用户的目标上下文。
|
||||
/// </summary>
|
||||
public static async Task<AnnouncementTargetContext> BuildAsync(
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor,
|
||||
IAdminAuthService? adminAuthService,
|
||||
IMiniAuthService? miniAuthService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var userId = currentUserAccessor?.UserId ?? 0;
|
||||
long? merchantId = null;
|
||||
IReadOnlyCollection<string> roles = Array.Empty<string>();
|
||||
IReadOnlyCollection<string> permissions = Array.Empty<string>();
|
||||
|
||||
if (userId != 0)
|
||||
{
|
||||
CurrentUserProfile? profile = null;
|
||||
if (adminAuthService != null)
|
||||
{
|
||||
profile = await adminAuthService.GetProfileAsync(userId, cancellationToken);
|
||||
}
|
||||
else if (miniAuthService != null)
|
||||
{
|
||||
profile = await miniAuthService.GetProfileAsync(userId, cancellationToken);
|
||||
}
|
||||
|
||||
if (profile != null)
|
||||
{
|
||||
merchantId = profile.MerchantId;
|
||||
roles = profile.Roles ?? Array.Empty<string>();
|
||||
permissions = profile.Permissions ?? Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
return new AnnouncementTargetContext
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
MerchantId = merchantId,
|
||||
Roles = roles,
|
||||
Permissions = permissions
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Targeting;
|
||||
|
||||
/// <summary>
|
||||
/// 目标受众过滤器。
|
||||
/// </summary>
|
||||
public static class TargetTypeFilter
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 判断公告是否匹配当前用户上下文。
|
||||
/// </summary>
|
||||
/// <param name="announcement">公告实体。</param>
|
||||
/// <param name="context">目标上下文。</param>
|
||||
/// <returns>是否匹配。</returns>
|
||||
public static bool IsMatch(TenantAnnouncement announcement, AnnouncementTargetContext context)
|
||||
{
|
||||
if (announcement == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var targetType = announcement.TargetType?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(targetType))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalized = targetType.ToUpperInvariant();
|
||||
var parsed = TryParseParameters(announcement.TargetParameters, out var payload);
|
||||
|
||||
return normalized switch
|
||||
{
|
||||
"ALL" => announcement.TenantId == 0
|
||||
? ApplyPayloadConstraints(payload, parsed, context, allowEmpty: true)
|
||||
: announcement.TenantId == context.TenantId
|
||||
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: true),
|
||||
"ALL_TENANTS" => ApplyPayloadConstraints(payload, parsed, context, allowEmpty: true),
|
||||
"TENANT_ALL" => announcement.TenantId == context.TenantId
|
||||
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: true),
|
||||
"SPECIFIC_TENANTS" => RequireTenantMatch(payload, parsed, context)
|
||||
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: false),
|
||||
"USERS" or "SPECIFIC_USERS" or "USER_IDS" => RequireUserMatch(payload, parsed, context)
|
||||
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: false),
|
||||
"ROLES" or "ROLE" => RequireRoleMatch(payload, parsed, context)
|
||||
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: false),
|
||||
"PERMISSIONS" or "PERMISSION" => RequirePermissionMatch(payload, parsed, context)
|
||||
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: false),
|
||||
"MERCHANTS" or "MERCHANT_IDS" => RequireMerchantMatch(payload, parsed, context)
|
||||
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: false),
|
||||
_ => ApplyPayloadConstraints(payload, parsed, context, allowEmpty: false)
|
||||
};
|
||||
}
|
||||
|
||||
private static bool RequireTenantMatch(TargetParametersPayload payload, bool parsed, AnnouncementTargetContext context)
|
||||
=> parsed && payload.TenantIds is { Length: > 0 } && payload.TenantIds.Contains(context.TenantId);
|
||||
|
||||
private static bool RequireUserMatch(TargetParametersPayload payload, bool parsed, AnnouncementTargetContext context)
|
||||
=> parsed && payload.UserIds is { Length: > 0 } && context.UserId != 0 && payload.UserIds.Contains(context.UserId);
|
||||
|
||||
private static bool RequireMerchantMatch(TargetParametersPayload payload, bool parsed, AnnouncementTargetContext context)
|
||||
=> parsed && payload.MerchantIds is { Length: > 0 } && context.MerchantId.HasValue && payload.MerchantIds.Contains(context.MerchantId.Value);
|
||||
|
||||
private static bool RequireRoleMatch(TargetParametersPayload payload, bool parsed, AnnouncementTargetContext context)
|
||||
=> parsed && payload.Roles is { Length: > 0 } && Intersects(payload.Roles, context.Roles);
|
||||
|
||||
private static bool RequirePermissionMatch(TargetParametersPayload payload, bool parsed, AnnouncementTargetContext context)
|
||||
=> parsed && payload.Permissions is { Length: > 0 } && Intersects(payload.Permissions, context.Permissions);
|
||||
|
||||
private static bool ApplyPayloadConstraints(
|
||||
TargetParametersPayload payload,
|
||||
bool parsed,
|
||||
AnnouncementTargetContext context,
|
||||
bool allowEmpty)
|
||||
{
|
||||
if (!parsed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!payload.HasConstraints)
|
||||
{
|
||||
return allowEmpty;
|
||||
}
|
||||
|
||||
if (payload.TenantIds is { Length: > 0 } && !payload.TenantIds.Contains(context.TenantId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.UserIds is { Length: > 0 })
|
||||
{
|
||||
if (context.UserId == 0 || !payload.UserIds.Contains(context.UserId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.MerchantIds is { Length: > 0 })
|
||||
{
|
||||
if (!context.MerchantId.HasValue || !payload.MerchantIds.Contains(context.MerchantId.Value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.Roles is { Length: > 0 } && !Intersects(payload.Roles, context.Roles))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.Permissions is { Length: > 0 } && !Intersects(payload.Permissions, context.Permissions))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.Departments is { Length: > 0 } && !Intersects(payload.Departments, context.Departments))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseParameters(string? json, out TargetParametersPayload payload)
|
||||
{
|
||||
payload = new TargetParametersPayload();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
payload = JsonSerializer.Deserialize<TargetParametersPayload>(json, Options) ?? new TargetParametersPayload();
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Intersects(IEnumerable<string> left, IEnumerable<string> right)
|
||||
{
|
||||
var set = new HashSet<string>(right ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var value in left ?? Array.Empty<string>())
|
||||
{
|
||||
if (set.Contains(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private sealed class TargetParametersPayload
|
||||
{
|
||||
public long[]? TenantIds { get; init; }
|
||||
public long[]? UserIds { get; init; }
|
||||
public long[]? MerchantIds { get; init; }
|
||||
public string[]? Roles { get; init; }
|
||||
public string[]? Permissions { get; init; }
|
||||
public string[]? Departments { get; init; }
|
||||
|
||||
public bool HasConstraints
|
||||
=> (TenantIds?.Length ?? 0) > 0
|
||||
|| (UserIds?.Length ?? 0) > 0
|
||||
|| (MerchantIds?.Length ?? 0) > 0
|
||||
|| (Roles?.Length ?? 0) > 0
|
||||
|| (Permissions?.Length ?? 0) > 0
|
||||
|| (Departments?.Length ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 目标受众上下文。
|
||||
/// </summary>
|
||||
public sealed record AnnouncementTargetContext
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
public long UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商户 ID(可选)。
|
||||
/// </summary>
|
||||
public long? MerchantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 角色集合。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> Roles { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 权限集合。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> Permissions { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 部门集合(可选)。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> Departments { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 自助注册租户命令验证器。
|
||||
/// </summary>
|
||||
public sealed class SelfRegisterTenantCommandValidator : AbstractValidator<SelfRegisterTenantCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public SelfRegisterTenantCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.AdminAccount)
|
||||
.NotEmpty()
|
||||
.MaximumLength(64)
|
||||
.Matches("^[A-Za-z0-9]+$")
|
||||
.WithMessage("登录账号仅允许大小写字母和数字");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user