fix: 平台订阅管理支持跨租户查询
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -15,46 +15,60 @@ public interface ISubscriptionRepository
|
||||
/// </summary>
|
||||
/// <param name="subscriptionId">订阅 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <param name="ignoreTenantFilter">是否忽略租户过滤(用于平台级查询/任务)。</param>
|
||||
/// <returns>订阅实体,未找到返回 null。</returns>
|
||||
Task<TenantSubscription?> FindByIdAsync(long subscriptionId, CancellationToken cancellationToken = default);
|
||||
Task<TenantSubscription?> FindByIdAsync(
|
||||
long subscriptionId,
|
||||
CancellationToken cancellationToken = default,
|
||||
bool ignoreTenantFilter = false);
|
||||
|
||||
/// <summary>
|
||||
/// 按 ID 列表批量查询订阅。
|
||||
/// </summary>
|
||||
/// <param name="subscriptionIds">订阅 ID 列表。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <param name="ignoreTenantFilter">是否忽略租户过滤(用于平台级查询/任务)。</param>
|
||||
/// <returns>订阅实体列表。</returns>
|
||||
Task<IReadOnlyList<TenantSubscription>> FindByIdsAsync(
|
||||
IEnumerable<long> subscriptionIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
CancellationToken cancellationToken = default,
|
||||
bool ignoreTenantFilter = false);
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询订阅列表(含关联信息)。
|
||||
/// </summary>
|
||||
/// <param name="filter">查询过滤条件。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <param name="ignoreTenantFilter">是否忽略租户过滤(用于平台级查询/任务)。</param>
|
||||
/// <returns>分页结果。</returns>
|
||||
Task<(IReadOnlyList<SubscriptionWithRelations> Items, int Total)> SearchPagedAsync(
|
||||
SubscriptionSearchFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
CancellationToken cancellationToken = default,
|
||||
bool ignoreTenantFilter = false);
|
||||
|
||||
/// <summary>
|
||||
/// 获取订阅详情(含关联信息)。
|
||||
/// </summary>
|
||||
/// <param name="subscriptionId">订阅 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <param name="ignoreTenantFilter">是否忽略租户过滤(用于平台级查询/任务)。</param>
|
||||
/// <returns>订阅详情信息。</returns>
|
||||
Task<SubscriptionDetailInfo?> GetDetailAsync(long subscriptionId, CancellationToken cancellationToken = default);
|
||||
Task<SubscriptionDetailInfo?> GetDetailAsync(
|
||||
long subscriptionId,
|
||||
CancellationToken cancellationToken = default,
|
||||
bool ignoreTenantFilter = false);
|
||||
|
||||
/// <summary>
|
||||
/// 按 ID 列表批量查询订阅(含租户信息)。
|
||||
/// </summary>
|
||||
/// <param name="subscriptionIds">订阅 ID 列表。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <param name="ignoreTenantFilter">是否忽略租户过滤(用于平台级查询/任务)。</param>
|
||||
/// <returns>订阅与租户信息列表。</returns>
|
||||
Task<IReadOnlyList<SubscriptionWithTenant>> FindByIdsWithTenantAsync(
|
||||
IEnumerable<long> subscriptionIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
CancellationToken cancellationToken = default,
|
||||
bool ignoreTenantFilter = false);
|
||||
|
||||
/// <summary>
|
||||
/// 查询自动续费候选订阅(活跃 + 开启自动续费 + 即将到期)。
|
||||
@@ -62,11 +76,13 @@ public interface ISubscriptionRepository
|
||||
/// <param name="now">当前时间(UTC)。</param>
|
||||
/// <param name="renewalThreshold">续费阈值时间(UTC),到期时间小于等于该时间视为候选。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <param name="ignoreTenantFilter">是否忽略租户过滤(用于平台级查询/任务)。</param>
|
||||
/// <returns>候选订阅集合(含套餐信息)。</returns>
|
||||
Task<IReadOnlyList<AutoRenewalCandidate>> FindAutoRenewalCandidatesAsync(
|
||||
DateTime now,
|
||||
DateTime renewalThreshold,
|
||||
CancellationToken cancellationToken = default);
|
||||
CancellationToken cancellationToken = default,
|
||||
bool ignoreTenantFilter = false);
|
||||
|
||||
/// <summary>
|
||||
/// 查询续费提醒候选订阅(活跃 + 未开启自动续费 + 到期时间落在指定日期范围)。
|
||||
@@ -74,21 +90,25 @@ public interface ISubscriptionRepository
|
||||
/// <param name="startOfDay">筛选开始时间(UTC,含)。</param>
|
||||
/// <param name="endOfDay">筛选结束时间(UTC,不含)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <param name="ignoreTenantFilter">是否忽略租户过滤(用于平台级查询/任务)。</param>
|
||||
/// <returns>候选订阅集合(含租户与套餐信息)。</returns>
|
||||
Task<IReadOnlyList<RenewalReminderCandidate>> FindRenewalReminderCandidatesAsync(
|
||||
DateTime startOfDay,
|
||||
DateTime endOfDay,
|
||||
CancellationToken cancellationToken = default);
|
||||
CancellationToken cancellationToken = default,
|
||||
bool ignoreTenantFilter = false);
|
||||
|
||||
/// <summary>
|
||||
/// 查询已到期仍处于 Active 的订阅(用于进入宽限期)。
|
||||
/// </summary>
|
||||
/// <param name="now">当前时间(UTC)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <param name="ignoreTenantFilter">是否忽略租户过滤(用于平台级查询/任务)。</param>
|
||||
/// <returns>到期订阅集合。</returns>
|
||||
Task<IReadOnlyList<TenantSubscription>> FindExpiredActiveSubscriptionsAsync(
|
||||
DateTime now,
|
||||
CancellationToken cancellationToken = default);
|
||||
CancellationToken cancellationToken = default,
|
||||
bool ignoreTenantFilter = false);
|
||||
|
||||
/// <summary>
|
||||
/// 查询宽限期已结束的订阅(用于自动暂停)。
|
||||
@@ -96,11 +116,13 @@ public interface ISubscriptionRepository
|
||||
/// <param name="now">当前时间(UTC)。</param>
|
||||
/// <param name="gracePeriodDays">宽限期天数。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <param name="ignoreTenantFilter">是否忽略租户过滤(用于平台级查询/任务)。</param>
|
||||
/// <returns>宽限期到期订阅集合。</returns>
|
||||
Task<IReadOnlyList<TenantSubscription>> FindGracePeriodExpiredSubscriptionsAsync(
|
||||
DateTime now,
|
||||
int gracePeriodDays,
|
||||
CancellationToken cancellationToken = default);
|
||||
CancellationToken cancellationToken = default,
|
||||
bool ignoreTenantFilter = false);
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -155,10 +177,12 @@ public interface ISubscriptionRepository
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <param name="ignoreTenantFilter">是否忽略租户过滤(用于平台级查询/任务)。</param>
|
||||
/// <returns>配额使用列表。</returns>
|
||||
Task<IReadOnlyList<TenantQuotaUsage>> GetQuotaUsagesAsync(
|
||||
long tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
CancellationToken cancellationToken = default,
|
||||
bool ignoreTenantFilter = false);
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
@@ -14,19 +14,31 @@ public sealed class EfSubscriptionRepository(TakeoutAppDbContext dbContext) : IS
|
||||
#region 订阅查询
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TenantSubscription?> FindByIdAsync(long subscriptionId, CancellationToken cancellationToken = default)
|
||||
public async Task<TenantSubscription?> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantSubscription>> FindByIdsAsync(
|
||||
IEnumerable<long> 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
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<SubscriptionWithRelations> 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
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SubscriptionDetailInfo?> GetDetailAsync(long subscriptionId, CancellationToken cancellationToken = default)
|
||||
public async Task<SubscriptionDetailInfo?> 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
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SubscriptionWithTenant>> FindByIdsWithTenantAsync(
|
||||
IEnumerable<long> 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<IReadOnlyList<AutoRenewalCandidate>> 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<IReadOnlyList<RenewalReminderCandidate>> 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
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantSubscription>> 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<IReadOnlyList<TenantSubscription>> 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
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantQuotaUsage>> 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);
|
||||
|
||||
Reference in New Issue
Block a user