fix: 平台订阅管理支持跨租户查询

This commit is contained in:
2025-12-18 23:58:24 +08:00
parent 30f44b6009
commit 00eb357e6e
14 changed files with 307 additions and 57 deletions

View File

@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
@@ -14,6 +15,7 @@ namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// </summary>
public sealed class BatchExtendSubscriptionsCommandHandler(
ISubscriptionRepository subscriptionRepository,
IHttpContextAccessor httpContextAccessor,
IIdGenerator idGenerator,
ILogger<BatchExtendSubscriptionsCommandHandler> logger)
: IRequestHandler<BatchExtendSubscriptionsCommand, BatchExtendResult>
@@ -21,6 +23,8 @@ public sealed class BatchExtendSubscriptionsCommandHandler(
/// <inheritdoc />
public async Task<BatchExtendResult> Handle(BatchExtendSubscriptionsCommand request, CancellationToken cancellationToken)
{
var ignoreTenantFilter = SubscriptionTenantAccess.ShouldIgnoreTenantFilter(httpContextAccessor);
var successCount = 0;
var failures = new List<BatchFailureItem>();
@@ -35,7 +39,10 @@ public sealed class BatchExtendSubscriptionsCommandHandler(
var extendMonths = request.DurationMonths ?? 0;
// 查询所有订阅
var subscriptions = await subscriptionRepository.FindByIdsAsync(request.SubscriptionIds, cancellationToken);
var subscriptions = await subscriptionRepository.FindByIdsAsync(
request.SubscriptionIds,
cancellationToken,
ignoreTenantFilter: ignoreTenantFilter);
foreach (var subscriptionId in request.SubscriptionIds)
{

View File

@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
@@ -14,6 +15,7 @@ namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// </summary>
public sealed class BatchSendReminderCommandHandler(
ISubscriptionRepository subscriptionRepository,
IHttpContextAccessor httpContextAccessor,
IIdGenerator idGenerator,
ILogger<BatchSendReminderCommandHandler> logger)
: IRequestHandler<BatchSendReminderCommand, BatchSendReminderResult>
@@ -21,13 +23,16 @@ public sealed class BatchSendReminderCommandHandler(
/// <inheritdoc />
public async Task<BatchSendReminderResult> Handle(BatchSendReminderCommand request, CancellationToken cancellationToken)
{
var ignoreTenantFilter = SubscriptionTenantAccess.ShouldIgnoreTenantFilter(httpContextAccessor);
var successCount = 0;
var failures = new List<BatchFailureItem>();
// 查询所有订阅及租户信息
var subscriptions = await subscriptionRepository.FindByIdsWithTenantAsync(
request.SubscriptionIds,
cancellationToken);
cancellationToken,
ignoreTenantFilter: ignoreTenantFilter);
foreach (var subscriptionId in request.SubscriptionIds)
{

View File

@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.AspNetCore.Http;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
using TakeoutSaaS.Application.App.Subscriptions.Queries;
@@ -14,6 +15,7 @@ namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// </summary>
public sealed class ChangeSubscriptionPlanCommandHandler(
ISubscriptionRepository subscriptionRepository,
IHttpContextAccessor httpContextAccessor,
IIdGenerator idGenerator,
IMediator mediator)
: IRequestHandler<ChangeSubscriptionPlanCommand, SubscriptionDetailDto?>
@@ -21,8 +23,13 @@ public sealed class ChangeSubscriptionPlanCommandHandler(
/// <inheritdoc />
public async Task<SubscriptionDetailDto?> Handle(ChangeSubscriptionPlanCommand request, CancellationToken cancellationToken)
{
var ignoreTenantFilter = SubscriptionTenantAccess.ShouldIgnoreTenantFilter(httpContextAccessor);
// 1. 查询订阅
var subscription = await subscriptionRepository.FindByIdAsync(request.SubscriptionId, cancellationToken);
var subscription = await subscriptionRepository.FindByIdAsync(
request.SubscriptionId,
cancellationToken,
ignoreTenantFilter: ignoreTenantFilter);
if (subscription == null)
{

View File

@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.AspNetCore.Http;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
using TakeoutSaaS.Application.App.Subscriptions.Queries;
@@ -14,6 +15,7 @@ namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// </summary>
public sealed class ExtendSubscriptionCommandHandler(
ISubscriptionRepository subscriptionRepository,
IHttpContextAccessor httpContextAccessor,
IIdGenerator idGenerator,
IMediator mediator)
: IRequestHandler<ExtendSubscriptionCommand, SubscriptionDetailDto?>
@@ -21,8 +23,13 @@ public sealed class ExtendSubscriptionCommandHandler(
/// <inheritdoc />
public async Task<SubscriptionDetailDto?> Handle(ExtendSubscriptionCommand request, CancellationToken cancellationToken)
{
var ignoreTenantFilter = SubscriptionTenantAccess.ShouldIgnoreTenantFilter(httpContextAccessor);
// 1. 查询订阅
var subscription = await subscriptionRepository.FindByIdAsync(request.SubscriptionId, cancellationToken);
var subscription = await subscriptionRepository.FindByIdAsync(
request.SubscriptionId,
cancellationToken,
ignoreTenantFilter: ignoreTenantFilter);
if (subscription == null)
{

View File

@@ -1,22 +1,31 @@
using MediatR;
using Microsoft.AspNetCore.Http;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
using TakeoutSaaS.Application.App.Subscriptions.Queries;
using TakeoutSaaS.Application.App.Tenants;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 订阅详情查询处理器。
/// </summary>
public sealed class GetSubscriptionDetailQueryHandler(ISubscriptionRepository subscriptionRepository)
public sealed class GetSubscriptionDetailQueryHandler(
ISubscriptionRepository subscriptionRepository,
IHttpContextAccessor httpContextAccessor)
: IRequestHandler<GetSubscriptionDetailQuery, SubscriptionDetailDto?>
{
/// <inheritdoc />
public async Task<SubscriptionDetailDto?> Handle(GetSubscriptionDetailQuery request, CancellationToken cancellationToken)
{
var ignoreTenantFilter = SubscriptionTenantAccess.ShouldIgnoreTenantFilter(httpContextAccessor);
// 1. 查询订阅基础信息
var detail = await subscriptionRepository.GetDetailAsync(request.SubscriptionId, cancellationToken);
var detail = await subscriptionRepository.GetDetailAsync(
request.SubscriptionId,
cancellationToken,
ignoreTenantFilter: ignoreTenantFilter);
if (detail == null)
{
@@ -26,17 +35,10 @@ public sealed class GetSubscriptionDetailQueryHandler(ISubscriptionRepository su
// 2. 查询配额使用情况
var quotaUsages = await subscriptionRepository.GetQuotaUsagesAsync(
detail.Subscription.TenantId,
cancellationToken);
cancellationToken,
ignoreTenantFilter: ignoreTenantFilter);
var quotaUsageDtos = quotaUsages.Select(q => new QuotaUsageDto
{
Id = q.Id,
QuotaType = q.QuotaType,
LimitValue = q.LimitValue,
UsedValue = q.UsedValue,
ResetCycle = q.ResetCycle,
LastResetAt = q.LastResetAt
}).ToList();
var quotaUsageDtos = BuildQuotaUsageDtos(detail.Package, quotaUsages);
// 3. 查询订阅变更历史(关联套餐信息)
var histories = await subscriptionRepository.GetHistoryAsync(request.SubscriptionId, cancellationToken);
@@ -81,4 +83,62 @@ public sealed class GetSubscriptionDetailQueryHandler(ISubscriptionRepository su
UpdatedAt = detail.Subscription.UpdatedAt
};
}
private static List<QuotaUsageDto> BuildQuotaUsageDtos(
TakeoutSaaS.Domain.Tenants.Entities.TenantPackage? package,
IReadOnlyList<TakeoutSaaS.Domain.Tenants.Entities.TenantQuotaUsage> quotaUsages)
{
var usageByType = quotaUsages
.GroupBy(u => u.QuotaType)
.ToDictionary(g => g.Key, g => g.First());
var baselineTypes = new List<(TenantQuotaType Type, decimal LimitValue)>();
if (package != null)
{
baselineTypes.Add((TenantQuotaType.StoreCount, package.MaxStoreCount.HasValue ? package.MaxStoreCount.Value : 0));
baselineTypes.Add((TenantQuotaType.AccountCount, package.MaxAccountCount.HasValue ? package.MaxAccountCount.Value : 0));
baselineTypes.Add((TenantQuotaType.Storage, package.MaxStorageGb.HasValue ? package.MaxStorageGb.Value : 0));
baselineTypes.Add((TenantQuotaType.SmsCredits, package.MaxSmsCredits.HasValue ? package.MaxSmsCredits.Value : 0));
baselineTypes.Add((TenantQuotaType.DeliveryOrders, package.MaxDeliveryOrders.HasValue ? package.MaxDeliveryOrders.Value : 0));
}
var results = new List<QuotaUsageDto>();
foreach (var (type, limitValue) in baselineTypes)
{
usageByType.TryGetValue(type, out var usage);
results.Add(new QuotaUsageDto
{
Id = usage?.Id ?? 0,
QuotaType = type,
LimitValue = limitValue,
UsedValue = usage?.UsedValue ?? 0,
ResetCycle = usage?.ResetCycle,
LastResetAt = usage?.LastResetAt
});
}
// Add any extra quota usages not covered by package fields (e.g. promotion slots).
foreach (var usage in usageByType.Values)
{
if (baselineTypes.Any(x => x.Type == usage.QuotaType))
{
continue;
}
results.Add(new QuotaUsageDto
{
Id = usage.Id,
QuotaType = usage.QuotaType,
LimitValue = usage.LimitValue,
UsedValue = usage.UsedValue,
ResetCycle = usage.ResetCycle,
LastResetAt = usage.LastResetAt
});
}
return results
.OrderBy(x => (int)x.QuotaType)
.ToList();
}
}

View File

@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.AspNetCore.Http;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
using TakeoutSaaS.Application.App.Subscriptions.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
@@ -9,12 +10,16 @@ namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 订阅分页查询处理器。
/// </summary>
public sealed class GetSubscriptionListQueryHandler(ISubscriptionRepository subscriptionRepository)
public sealed class GetSubscriptionListQueryHandler(
ISubscriptionRepository subscriptionRepository,
IHttpContextAccessor httpContextAccessor)
: IRequestHandler<GetSubscriptionListQuery, PagedResult<SubscriptionListDto>>
{
/// <inheritdoc />
public async Task<PagedResult<SubscriptionListDto>> Handle(GetSubscriptionListQuery request, CancellationToken cancellationToken)
{
var ignoreTenantFilter = SubscriptionTenantAccess.ShouldIgnoreTenantFilter(httpContextAccessor);
// 1. 构建查询过滤条件
var filter = new SubscriptionSearchFilter
{
@@ -29,7 +34,10 @@ public sealed class GetSubscriptionListQueryHandler(ISubscriptionRepository subs
};
// 2. 执行分页查询
var (items, total) = await subscriptionRepository.SearchPagedAsync(filter, cancellationToken);
var (items, total) = await subscriptionRepository.SearchPagedAsync(
filter,
cancellationToken,
ignoreTenantFilter: ignoreTenantFilter);
// 3. 映射为 DTO
var dtos = items.Select(x => new SubscriptionListDto

View File

@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
@@ -13,6 +14,7 @@ namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// </summary>
public sealed class ProcessAutoRenewalCommandHandler(
ISubscriptionRepository subscriptionRepository,
IHttpContextAccessor httpContextAccessor,
ITenantBillingRepository billingRepository,
IIdGenerator idGenerator,
ILogger<ProcessAutoRenewalCommandHandler> logger)
@@ -21,12 +23,18 @@ public sealed class ProcessAutoRenewalCommandHandler(
/// <inheritdoc />
public async Task<ProcessAutoRenewalResult> Handle(ProcessAutoRenewalCommand request, CancellationToken cancellationToken)
{
var ignoreTenantFilter = SubscriptionTenantAccess.ShouldIgnoreTenantFilter(httpContextAccessor);
// 1. 计算续费阈值时间
var now = DateTime.UtcNow;
var renewalThreshold = now.AddDays(request.RenewalDaysBeforeExpiry);
// 2. 查询候选订阅(含套餐)
var candidates = await subscriptionRepository.FindAutoRenewalCandidatesAsync(now, renewalThreshold, cancellationToken);
var candidates = await subscriptionRepository.FindAutoRenewalCandidatesAsync(
now,
renewalThreshold,
cancellationToken,
ignoreTenantFilter: ignoreTenantFilter);
var createdBillCount = 0;
// 3. 遍历候选订阅,生成账单
@@ -132,4 +140,3 @@ public sealed class ProcessAutoRenewalCommandHandler(
return yearlyPrice.Value * years + monthlyPrice * remainingMonths;
}
}

View File

@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
@@ -14,6 +15,7 @@ namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// </summary>
public sealed class ProcessRenewalRemindersCommandHandler(
ISubscriptionRepository subscriptionRepository,
IHttpContextAccessor httpContextAccessor,
ITenantNotificationRepository notificationRepository,
IIdGenerator idGenerator,
ILogger<ProcessRenewalRemindersCommandHandler> logger)
@@ -24,6 +26,8 @@ public sealed class ProcessRenewalRemindersCommandHandler(
/// <inheritdoc />
public async Task<ProcessRenewalRemindersResult> Handle(ProcessRenewalRemindersCommand request, CancellationToken cancellationToken)
{
var ignoreTenantFilter = SubscriptionTenantAccess.ShouldIgnoreTenantFilter(httpContextAccessor);
// 1. 读取提醒配置
var now = DateTime.UtcNow;
var candidateCount = 0;
@@ -39,7 +43,11 @@ public sealed class ProcessRenewalRemindersCommandHandler(
var endOfDay = startOfDay.AddDays(1);
// 2.2 查询候选订阅(活跃 + 未开自动续费 + 到期在当日)
var candidates = await subscriptionRepository.FindRenewalReminderCandidatesAsync(startOfDay, endOfDay, cancellationToken);
var candidates = await subscriptionRepository.FindRenewalReminderCandidatesAsync(
startOfDay,
endOfDay,
cancellationToken,
ignoreTenantFilter: ignoreTenantFilter);
candidateCount += candidates.Count;
foreach (var item in candidates)
@@ -112,4 +120,3 @@ public sealed class ProcessRenewalRemindersCommandHandler(
});
}
}

View File

@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Domain.Tenants.Enums;
@@ -11,16 +12,26 @@ namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// </summary>
public sealed class ProcessSubscriptionExpiryCommandHandler(
ISubscriptionRepository subscriptionRepository,
IHttpContextAccessor httpContextAccessor,
ILogger<ProcessSubscriptionExpiryCommandHandler> logger)
: IRequestHandler<ProcessSubscriptionExpiryCommand, ProcessSubscriptionExpiryResult>
{
/// <inheritdoc />
public async Task<ProcessSubscriptionExpiryResult> Handle(ProcessSubscriptionExpiryCommand request, CancellationToken cancellationToken)
{
var ignoreTenantFilter = SubscriptionTenantAccess.ShouldIgnoreTenantFilter(httpContextAccessor);
// 1. 查询到期订阅
var now = DateTime.UtcNow;
var expiredActive = await subscriptionRepository.FindExpiredActiveSubscriptionsAsync(now, cancellationToken);
var gracePeriodExpired = await subscriptionRepository.FindGracePeriodExpiredSubscriptionsAsync(now, request.GracePeriodDays, cancellationToken);
var expiredActive = await subscriptionRepository.FindExpiredActiveSubscriptionsAsync(
now,
cancellationToken,
ignoreTenantFilter: ignoreTenantFilter);
var gracePeriodExpired = await subscriptionRepository.FindGracePeriodExpiredSubscriptionsAsync(
now,
request.GracePeriodDays,
cancellationToken,
ignoreTenantFilter: ignoreTenantFilter);
// 2. 更新订阅状态
foreach (var subscription in expiredActive)
@@ -56,4 +67,3 @@ public sealed class ProcessSubscriptionExpiryCommandHandler(
};
}
}

View File

@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.AspNetCore.Http;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
using TakeoutSaaS.Application.App.Subscriptions.Queries;
@@ -11,14 +12,20 @@ namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// </summary>
public sealed class UpdateSubscriptionCommandHandler(
ISubscriptionRepository subscriptionRepository,
IHttpContextAccessor httpContextAccessor,
IMediator mediator)
: IRequestHandler<UpdateSubscriptionCommand, SubscriptionDetailDto?>
{
/// <inheritdoc />
public async Task<SubscriptionDetailDto?> Handle(UpdateSubscriptionCommand request, CancellationToken cancellationToken)
{
var ignoreTenantFilter = SubscriptionTenantAccess.ShouldIgnoreTenantFilter(httpContextAccessor);
// 1. 查询订阅
var subscription = await subscriptionRepository.FindByIdAsync(request.SubscriptionId, cancellationToken);
var subscription = await subscriptionRepository.FindByIdAsync(
request.SubscriptionId,
cancellationToken,
ignoreTenantFilter: ignoreTenantFilter);
if (subscription == null)
{

View File

@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.AspNetCore.Http;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
using TakeoutSaaS.Application.App.Subscriptions.Queries;
@@ -11,14 +12,20 @@ namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// </summary>
public sealed class UpdateSubscriptionStatusCommandHandler(
ISubscriptionRepository subscriptionRepository,
IHttpContextAccessor httpContextAccessor,
IMediator mediator)
: IRequestHandler<UpdateSubscriptionStatusCommand, SubscriptionDetailDto?>
{
/// <inheritdoc />
public async Task<SubscriptionDetailDto?> Handle(UpdateSubscriptionStatusCommand request, CancellationToken cancellationToken)
{
var ignoreTenantFilter = SubscriptionTenantAccess.ShouldIgnoreTenantFilter(httpContextAccessor);
// 1. 查询订阅
var subscription = await subscriptionRepository.FindByIdAsync(request.SubscriptionId, cancellationToken);
var subscription = await subscriptionRepository.FindByIdAsync(
request.SubscriptionId,
cancellationToken,
ignoreTenantFilter: ignoreTenantFilter);
if (subscription == null)
{

View File

@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Http;
using System.Security.Claims;
namespace TakeoutSaaS.Application.App.Subscriptions;
internal static class SubscriptionTenantAccess
{
private const string PermissionClaimType = "permission";
private const string PlatformAdminRole = "PlatformAdmin";
public static bool ShouldIgnoreTenantFilter(IHttpContextAccessor httpContextAccessor)
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext == null)
{
// Background jobs / out-of-request execution should process across tenants.
return true;
}
var user = httpContext.User;
if (user?.Identity?.IsAuthenticated != true)
{
return false;
}
if (user.IsInRole(PlatformAdminRole))
{
return true;
}
var permissions = user.FindAll(PermissionClaimType)
.Select(c => c.Value?.Trim())
.Where(v => !string.IsNullOrWhiteSpace(v))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
// Platform-level tenant permissions imply cross-tenant visibility.
return permissions.Contains("tenant:read");
}
}