From 00eb357e6ef5ad9f87a0c0c9ba3cbed57b71efd0 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 18 Dec 2025 23:58:24 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=B9=B3=E5=8F=B0=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=94=AF=E6=8C=81=E8=B7=A8=E7=A7=9F=E6=88=B7?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BatchExtendSubscriptionsCommandHandler.cs | 9 +- .../BatchSendReminderCommandHandler.cs | 7 +- .../ChangeSubscriptionPlanCommandHandler.cs | 9 +- .../ExtendSubscriptionCommandHandler.cs | 9 +- .../GetSubscriptionDetailQueryHandler.cs | 84 +++++++++++++--- .../GetSubscriptionListQueryHandler.cs | 12 ++- .../ProcessAutoRenewalCommandHandler.cs | 11 ++- .../ProcessRenewalRemindersCommandHandler.cs | 11 ++- ...ProcessSubscriptionExpiryCommandHandler.cs | 16 +++- .../UpdateSubscriptionCommandHandler.cs | 9 +- .../UpdateSubscriptionStatusCommandHandler.cs | 9 +- .../Subscriptions/SubscriptionTenantAccess.cs | 39 ++++++++ .../Repositories/ISubscriptionRepository.cs | 44 +++++++-- .../Repositories/EfSubscriptionRepository.cs | 95 +++++++++++++++---- 14 files changed, 307 insertions(+), 57 deletions(-) create mode 100644 src/Application/TakeoutSaaS.Application/App/Subscriptions/SubscriptionTenantAccess.cs diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/BatchExtendSubscriptionsCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/BatchExtendSubscriptionsCommandHandler.cs index 3a63b63..2760e90 100644 --- a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/BatchExtendSubscriptionsCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/BatchExtendSubscriptionsCommandHandler.cs @@ -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; /// public sealed class BatchExtendSubscriptionsCommandHandler( ISubscriptionRepository subscriptionRepository, + IHttpContextAccessor httpContextAccessor, IIdGenerator idGenerator, ILogger logger) : IRequestHandler @@ -21,6 +23,8 @@ public sealed class BatchExtendSubscriptionsCommandHandler( /// public async Task Handle(BatchExtendSubscriptionsCommand request, CancellationToken cancellationToken) { + var ignoreTenantFilter = SubscriptionTenantAccess.ShouldIgnoreTenantFilter(httpContextAccessor); + var successCount = 0; var failures = new List(); @@ -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) { diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/BatchSendReminderCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/BatchSendReminderCommandHandler.cs index 95f36b1..cea5313 100644 --- a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/BatchSendReminderCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/BatchSendReminderCommandHandler.cs @@ -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; /// public sealed class BatchSendReminderCommandHandler( ISubscriptionRepository subscriptionRepository, + IHttpContextAccessor httpContextAccessor, IIdGenerator idGenerator, ILogger logger) : IRequestHandler @@ -21,13 +23,16 @@ public sealed class BatchSendReminderCommandHandler( /// public async Task Handle(BatchSendReminderCommand request, CancellationToken cancellationToken) { + var ignoreTenantFilter = SubscriptionTenantAccess.ShouldIgnoreTenantFilter(httpContextAccessor); + var successCount = 0; var failures = new List(); // 查询所有订阅及租户信息 var subscriptions = await subscriptionRepository.FindByIdsWithTenantAsync( request.SubscriptionIds, - cancellationToken); + cancellationToken, + ignoreTenantFilter: ignoreTenantFilter); foreach (var subscriptionId in request.SubscriptionIds) { diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ChangeSubscriptionPlanCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ChangeSubscriptionPlanCommandHandler.cs index 5a238a2..7d443c0 100644 --- a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ChangeSubscriptionPlanCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ChangeSubscriptionPlanCommandHandler.cs @@ -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; /// public sealed class ChangeSubscriptionPlanCommandHandler( ISubscriptionRepository subscriptionRepository, + IHttpContextAccessor httpContextAccessor, IIdGenerator idGenerator, IMediator mediator) : IRequestHandler @@ -21,8 +23,13 @@ public sealed class ChangeSubscriptionPlanCommandHandler( /// public async Task 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) { diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ExtendSubscriptionCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ExtendSubscriptionCommandHandler.cs index 98fc903..fbfb825 100644 --- a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ExtendSubscriptionCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ExtendSubscriptionCommandHandler.cs @@ -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; /// public sealed class ExtendSubscriptionCommandHandler( ISubscriptionRepository subscriptionRepository, + IHttpContextAccessor httpContextAccessor, IIdGenerator idGenerator, IMediator mediator) : IRequestHandler @@ -21,8 +23,13 @@ public sealed class ExtendSubscriptionCommandHandler( /// public async Task 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) { diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/GetSubscriptionDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/GetSubscriptionDetailQueryHandler.cs index 6bb13ab..f77fda1 100644 --- a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/GetSubscriptionDetailQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/GetSubscriptionDetailQueryHandler.cs @@ -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; /// /// 订阅详情查询处理器。 /// -public sealed class GetSubscriptionDetailQueryHandler(ISubscriptionRepository subscriptionRepository) +public sealed class GetSubscriptionDetailQueryHandler( + ISubscriptionRepository subscriptionRepository, + IHttpContextAccessor httpContextAccessor) : IRequestHandler { /// public async Task 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 BuildQuotaUsageDtos( + TakeoutSaaS.Domain.Tenants.Entities.TenantPackage? package, + IReadOnlyList 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(); + + 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(); + } } diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/GetSubscriptionListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/GetSubscriptionListQueryHandler.cs index 75146b7..58d8268 100644 --- a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/GetSubscriptionListQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/GetSubscriptionListQueryHandler.cs @@ -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; /// /// 订阅分页查询处理器。 /// -public sealed class GetSubscriptionListQueryHandler(ISubscriptionRepository subscriptionRepository) +public sealed class GetSubscriptionListQueryHandler( + ISubscriptionRepository subscriptionRepository, + IHttpContextAccessor httpContextAccessor) : IRequestHandler> { /// public async Task> 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 diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessAutoRenewalCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessAutoRenewalCommandHandler.cs index a8f209b..80d8048 100644 --- a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessAutoRenewalCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessAutoRenewalCommandHandler.cs @@ -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; /// public sealed class ProcessAutoRenewalCommandHandler( ISubscriptionRepository subscriptionRepository, + IHttpContextAccessor httpContextAccessor, ITenantBillingRepository billingRepository, IIdGenerator idGenerator, ILogger logger) @@ -21,12 +23,18 @@ public sealed class ProcessAutoRenewalCommandHandler( /// public async Task 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; } } - diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessRenewalRemindersCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessRenewalRemindersCommandHandler.cs index eba2fe6..27be88d 100644 --- a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessRenewalRemindersCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessRenewalRemindersCommandHandler.cs @@ -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; /// public sealed class ProcessRenewalRemindersCommandHandler( ISubscriptionRepository subscriptionRepository, + IHttpContextAccessor httpContextAccessor, ITenantNotificationRepository notificationRepository, IIdGenerator idGenerator, ILogger logger) @@ -24,6 +26,8 @@ public sealed class ProcessRenewalRemindersCommandHandler( /// public async Task 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( }); } } - diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessSubscriptionExpiryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessSubscriptionExpiryCommandHandler.cs index 4d25680..bc55111 100644 --- a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessSubscriptionExpiryCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessSubscriptionExpiryCommandHandler.cs @@ -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; /// public sealed class ProcessSubscriptionExpiryCommandHandler( ISubscriptionRepository subscriptionRepository, + IHttpContextAccessor httpContextAccessor, ILogger logger) : IRequestHandler { /// public async Task 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( }; } } - diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateSubscriptionCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateSubscriptionCommandHandler.cs index a3d531f..225700f 100644 --- a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateSubscriptionCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateSubscriptionCommandHandler.cs @@ -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; /// public sealed class UpdateSubscriptionCommandHandler( ISubscriptionRepository subscriptionRepository, + IHttpContextAccessor httpContextAccessor, IMediator mediator) : IRequestHandler { /// public async Task 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) { diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateSubscriptionStatusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateSubscriptionStatusCommandHandler.cs index b75e50d..9f32016 100644 --- a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateSubscriptionStatusCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateSubscriptionStatusCommandHandler.cs @@ -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; /// public sealed class UpdateSubscriptionStatusCommandHandler( ISubscriptionRepository subscriptionRepository, + IHttpContextAccessor httpContextAccessor, IMediator mediator) : IRequestHandler { /// public async Task 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) { diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/SubscriptionTenantAccess.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/SubscriptionTenantAccess.cs new file mode 100644 index 0000000..356ada2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/SubscriptionTenantAccess.cs @@ -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"); + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ISubscriptionRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ISubscriptionRepository.cs index 6875b76..95d267e 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ISubscriptionRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ISubscriptionRepository.cs @@ -15,46 +15,60 @@ public interface ISubscriptionRepository /// /// 订阅 ID。 /// 取消标记。 + /// 是否忽略租户过滤(用于平台级查询/任务)。 /// 订阅实体,未找到返回 null。 - Task FindByIdAsync(long subscriptionId, CancellationToken cancellationToken = default); + Task FindByIdAsync( + long subscriptionId, + CancellationToken cancellationToken = default, + bool ignoreTenantFilter = false); /// /// 按 ID 列表批量查询订阅。 /// /// 订阅 ID 列表。 /// 取消标记。 + /// 是否忽略租户过滤(用于平台级查询/任务)。 /// 订阅实体列表。 Task> FindByIdsAsync( IEnumerable subscriptionIds, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + bool ignoreTenantFilter = false); /// /// 分页查询订阅列表(含关联信息)。 /// /// 查询过滤条件。 /// 取消标记。 + /// 是否忽略租户过滤(用于平台级查询/任务)。 /// 分页结果。 Task<(IReadOnlyList Items, int Total)> SearchPagedAsync( SubscriptionSearchFilter filter, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + bool ignoreTenantFilter = false); /// /// 获取订阅详情(含关联信息)。 /// /// 订阅 ID。 /// 取消标记。 + /// 是否忽略租户过滤(用于平台级查询/任务)。 /// 订阅详情信息。 - Task GetDetailAsync(long subscriptionId, CancellationToken cancellationToken = default); + Task GetDetailAsync( + long subscriptionId, + CancellationToken cancellationToken = default, + bool ignoreTenantFilter = false); /// /// 按 ID 列表批量查询订阅(含租户信息)。 /// /// 订阅 ID 列表。 /// 取消标记。 + /// 是否忽略租户过滤(用于平台级查询/任务)。 /// 订阅与租户信息列表。 Task> FindByIdsWithTenantAsync( IEnumerable subscriptionIds, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + bool ignoreTenantFilter = false); /// /// 查询自动续费候选订阅(活跃 + 开启自动续费 + 即将到期)。 @@ -62,11 +76,13 @@ public interface ISubscriptionRepository /// 当前时间(UTC)。 /// 续费阈值时间(UTC),到期时间小于等于该时间视为候选。 /// 取消标记。 + /// 是否忽略租户过滤(用于平台级查询/任务)。 /// 候选订阅集合(含套餐信息)。 Task> FindAutoRenewalCandidatesAsync( DateTime now, DateTime renewalThreshold, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + bool ignoreTenantFilter = false); /// /// 查询续费提醒候选订阅(活跃 + 未开启自动续费 + 到期时间落在指定日期范围)。 @@ -74,21 +90,25 @@ public interface ISubscriptionRepository /// 筛选开始时间(UTC,含)。 /// 筛选结束时间(UTC,不含)。 /// 取消标记。 + /// 是否忽略租户过滤(用于平台级查询/任务)。 /// 候选订阅集合(含租户与套餐信息)。 Task> FindRenewalReminderCandidatesAsync( DateTime startOfDay, DateTime endOfDay, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + bool ignoreTenantFilter = false); /// /// 查询已到期仍处于 Active 的订阅(用于进入宽限期)。 /// /// 当前时间(UTC)。 /// 取消标记。 + /// 是否忽略租户过滤(用于平台级查询/任务)。 /// 到期订阅集合。 Task> FindExpiredActiveSubscriptionsAsync( DateTime now, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + bool ignoreTenantFilter = false); /// /// 查询宽限期已结束的订阅(用于自动暂停)。 @@ -96,11 +116,13 @@ public interface ISubscriptionRepository /// 当前时间(UTC)。 /// 宽限期天数。 /// 取消标记。 + /// 是否忽略租户过滤(用于平台级查询/任务)。 /// 宽限期到期订阅集合。 Task> FindGracePeriodExpiredSubscriptionsAsync( DateTime now, int gracePeriodDays, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + bool ignoreTenantFilter = false); #endregion @@ -155,10 +177,12 @@ public interface ISubscriptionRepository /// /// 租户 ID。 /// 取消标记。 + /// 是否忽略租户过滤(用于平台级查询/任务)。 /// 配额使用列表。 Task> GetQuotaUsagesAsync( long tenantId, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + bool ignoreTenantFilter = false); #endregion diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfSubscriptionRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfSubscriptionRepository.cs index 42c9a34..0b59a3b 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfSubscriptionRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfSubscriptionRepository.cs @@ -14,19 +14,31 @@ public sealed class EfSubscriptionRepository(TakeoutAppDbContext dbContext) : IS #region 订阅查询 /// - public async Task FindByIdAsync(long subscriptionId, CancellationToken cancellationToken = default) + public async Task FindByIdAsync( + long subscriptionId, + CancellationToken cancellationToken = default, + bool ignoreTenantFilter = false) { - return await dbContext.TenantSubscriptions + var query = ignoreTenantFilter + ? dbContext.TenantSubscriptions.IgnoreQueryFilters() + : dbContext.TenantSubscriptions; + + return await query .FirstOrDefaultAsync(s => s.Id == subscriptionId, cancellationToken); } /// public async Task> FindByIdsAsync( IEnumerable subscriptionIds, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + bool ignoreTenantFilter = false) { var ids = subscriptionIds.ToList(); - return await dbContext.TenantSubscriptions + var query = ignoreTenantFilter + ? dbContext.TenantSubscriptions.IgnoreQueryFilters() + : dbContext.TenantSubscriptions; + + return await query .Where(s => ids.Contains(s.Id)) .ToListAsync(cancellationToken); } @@ -34,10 +46,15 @@ public sealed class EfSubscriptionRepository(TakeoutAppDbContext dbContext) : IS /// public async Task<(IReadOnlyList Items, int Total)> SearchPagedAsync( SubscriptionSearchFilter filter, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + bool ignoreTenantFilter = false) { // 1. 构建基础查询 - var query = dbContext.TenantSubscriptions + var subscriptionQuery = ignoreTenantFilter + ? dbContext.TenantSubscriptions.IgnoreQueryFilters() + : dbContext.TenantSubscriptions; + + var query = subscriptionQuery .AsNoTracking() .Join( dbContext.Tenants, @@ -113,9 +130,16 @@ public sealed class EfSubscriptionRepository(TakeoutAppDbContext dbContext) : IS } /// - public async Task GetDetailAsync(long subscriptionId, CancellationToken cancellationToken = default) + public async Task GetDetailAsync( + long subscriptionId, + CancellationToken cancellationToken = default, + bool ignoreTenantFilter = false) { - var result = await dbContext.TenantSubscriptions + var subscriptionQuery = ignoreTenantFilter + ? dbContext.TenantSubscriptions.IgnoreQueryFilters() + : dbContext.TenantSubscriptions; + + var result = await subscriptionQuery .AsNoTracking() .Where(s => s.Id == subscriptionId) .Select(s => new @@ -147,10 +171,16 @@ public sealed class EfSubscriptionRepository(TakeoutAppDbContext dbContext) : IS /// public async Task> FindByIdsWithTenantAsync( IEnumerable subscriptionIds, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + bool ignoreTenantFilter = false) { var ids = subscriptionIds.ToList(); - return await dbContext.TenantSubscriptions + + var query = ignoreTenantFilter + ? dbContext.TenantSubscriptions.IgnoreQueryFilters() + : dbContext.TenantSubscriptions; + + return await query .Where(s => ids.Contains(s.Id)) .Join( dbContext.Tenants, @@ -169,10 +199,15 @@ public sealed class EfSubscriptionRepository(TakeoutAppDbContext dbContext) : IS public async Task> FindAutoRenewalCandidatesAsync( DateTime now, DateTime renewalThreshold, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + bool ignoreTenantFilter = false) { // 1. 查询开启自动续费且即将到期的活跃订阅 - var query = dbContext.TenantSubscriptions + var subscriptionQuery = ignoreTenantFilter + ? dbContext.TenantSubscriptions.IgnoreQueryFilters() + : dbContext.TenantSubscriptions; + + var query = subscriptionQuery .Where(s => s.Status == SubscriptionStatus.Active && s.AutoRenew && s.EffectiveTo <= renewalThreshold @@ -195,10 +230,15 @@ public sealed class EfSubscriptionRepository(TakeoutAppDbContext dbContext) : IS public async Task> FindRenewalReminderCandidatesAsync( DateTime startOfDay, DateTime endOfDay, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + bool ignoreTenantFilter = false) { // 1. 查询到期落在指定区间的订阅(且未开启自动续费) - var query = dbContext.TenantSubscriptions + var subscriptionQuery = ignoreTenantFilter + ? dbContext.TenantSubscriptions.IgnoreQueryFilters() + : dbContext.TenantSubscriptions; + + var query = subscriptionQuery .Where(s => s.Status == SubscriptionStatus.Active && !s.AutoRenew && s.EffectiveTo >= startOfDay @@ -226,10 +266,15 @@ public sealed class EfSubscriptionRepository(TakeoutAppDbContext dbContext) : IS /// public async Task> FindExpiredActiveSubscriptionsAsync( DateTime now, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + bool ignoreTenantFilter = false) { + var query = ignoreTenantFilter + ? dbContext.TenantSubscriptions.IgnoreQueryFilters() + : dbContext.TenantSubscriptions; + // 1. 查询已到期仍为 Active 的订阅 - return await dbContext.TenantSubscriptions + return await query .Where(s => s.Status == SubscriptionStatus.Active && s.EffectiveTo < now) .ToListAsync(cancellationToken); } @@ -238,10 +283,15 @@ public sealed class EfSubscriptionRepository(TakeoutAppDbContext dbContext) : IS public async Task> FindGracePeriodExpiredSubscriptionsAsync( DateTime now, int gracePeriodDays, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + bool ignoreTenantFilter = false) { + var query = ignoreTenantFilter + ? dbContext.TenantSubscriptions.IgnoreQueryFilters() + : dbContext.TenantSubscriptions; + // 1. 查询宽限期已结束的订阅 - return await dbContext.TenantSubscriptions + return await query .Where(s => s.Status == SubscriptionStatus.GracePeriod && s.EffectiveTo.AddDays(gracePeriodDays) < now) .ToListAsync(cancellationToken); @@ -312,9 +362,14 @@ public sealed class EfSubscriptionRepository(TakeoutAppDbContext dbContext) : IS /// public async Task> GetQuotaUsagesAsync( long tenantId, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + bool ignoreTenantFilter = false) { - return await dbContext.TenantQuotaUsages + var query = ignoreTenantFilter + ? dbContext.TenantQuotaUsages.IgnoreQueryFilters() + : dbContext.TenantQuotaUsages; + + return await query .AsNoTracking() .Where(q => q.TenantId == tenantId) .ToListAsync(cancellationToken);