refactor: 清理租户API旧模块代码

This commit is contained in:
2026-02-17 09:57:26 +08:00
parent 2711893474
commit 992930a821
924 changed files with 7 additions and 191722 deletions

View File

@@ -1,72 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 批量延期订阅命令。
/// </summary>
public sealed record BatchExtendSubscriptionsCommand : IRequest<BatchExtendResult>
{
/// <summary>
/// 订阅ID列表。
/// </summary>
[Required]
[MinLength(1, ErrorMessage = "至少需要选择一个订阅")]
public IReadOnlyList<long> SubscriptionIds { get; init; } = Array.Empty<long>();
/// <summary>
/// 延期时长(天)。
/// </summary>
[Range(1, 3650, ErrorMessage = "延期天数必须在1-3650天之间")]
public int? DurationDays { get; init; }
/// <summary>
/// 延期时长(月)。
/// </summary>
[Range(1, 120, ErrorMessage = "延期月数必须在1-120月之间")]
public int? DurationMonths { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
[MaxLength(500)]
public string? Notes { get; init; }
}
/// <summary>
/// 批量延期结果。
/// </summary>
public record BatchExtendResult
{
/// <summary>
/// 成功数量。
/// </summary>
public int SuccessCount { get; init; }
/// <summary>
/// 失败数量。
/// </summary>
public int FailureCount { get; init; }
/// <summary>
/// 失败详情列表。
/// </summary>
public IReadOnlyList<BatchFailureItem> Failures { get; init; } = Array.Empty<BatchFailureItem>();
}
/// <summary>
/// 批量操作失败项。
/// </summary>
public record BatchFailureItem
{
/// <summary>
/// 订阅ID。
/// </summary>
public long SubscriptionId { get; init; }
/// <summary>
/// 失败原因。
/// </summary>
public string Reason { get; init; } = string.Empty;
}

View File

@@ -1,45 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 批量发送续费提醒命令。
/// </summary>
public sealed record BatchSendReminderCommand : IRequest<BatchSendReminderResult>
{
/// <summary>
/// 订阅ID列表。
/// </summary>
[Required]
[MinLength(1, ErrorMessage = "至少需要选择一个订阅")]
public IReadOnlyList<long> SubscriptionIds { get; init; } = Array.Empty<long>();
/// <summary>
/// 提醒内容。
/// </summary>
[Required(ErrorMessage = "提醒内容不能为空")]
[MaxLength(1000, ErrorMessage = "提醒内容不能超过1000字符")]
public string ReminderContent { get; init; } = string.Empty;
}
/// <summary>
/// 批量发送提醒结果。
/// </summary>
public record BatchSendReminderResult
{
/// <summary>
/// 成功发送数量。
/// </summary>
public int SuccessCount { get; init; }
/// <summary>
/// 发送失败数量。
/// </summary>
public int FailureCount { get; init; }
/// <summary>
/// 失败详情列表。
/// </summary>
public IReadOnlyList<BatchFailureItem> Failures { get; init; } = Array.Empty<BatchFailureItem>();
}

View File

@@ -1,34 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 变更套餐命令。
/// </summary>
public sealed record ChangeSubscriptionPlanCommand : IRequest<SubscriptionDetailDto?>
{
/// <summary>
/// 订阅 ID从路由参数绑定
/// </summary>
[Required]
public long SubscriptionId { get; init; }
/// <summary>
/// 目标套餐 ID。
/// </summary>
[Required]
public long TargetPackageId { get; init; }
/// <summary>
/// 是否立即生效,否则在下周期生效。
/// </summary>
public bool Immediate { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
[MaxLength(500)]
public string? Notes { get; init; }
}

View File

@@ -1,29 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 延期订阅命令。
/// </summary>
public sealed record ExtendSubscriptionCommand : IRequest<SubscriptionDetailDto?>
{
/// <summary>
/// 订阅 ID从路由参数绑定
/// </summary>
[Required]
public long SubscriptionId { get; init; }
/// <summary>
/// 延期时长(月)。
/// </summary>
[Range(1, 120)]
public int DurationMonths { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
[MaxLength(500)]
public string? Notes { get; init; }
}

View File

@@ -1,33 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 处理自动续费:为开启自动续费且即将到期的订阅生成续费账单。
/// </summary>
public sealed record ProcessAutoRenewalCommand : IRequest<ProcessAutoRenewalResult>
{
/// <summary>
/// 到期前 N 天生成续费账单。
/// </summary>
[Range(0, 365, ErrorMessage = "续费提前天数必须在 0~365 之间")]
public int RenewalDaysBeforeExpiry { get; init; } = 3;
}
/// <summary>
/// 自动续费处理结果。
/// </summary>
public sealed record ProcessAutoRenewalResult
{
/// <summary>
/// 扫描到的候选订阅数量。
/// </summary>
public int CandidateCount { get; init; }
/// <summary>
/// 实际创建的账单数量。
/// </summary>
public int CreatedBillCount { get; init; }
}

View File

@@ -1,33 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 处理续费提醒:按到期前指定天数批量创建站内提醒通知(幂等)。
/// </summary>
public sealed record ProcessRenewalRemindersCommand : IRequest<ProcessRenewalRemindersResult>
{
/// <summary>
/// 提醒时间点(到期前 N 天)。
/// </summary>
[MinLength(1, ErrorMessage = "至少需要配置一个提醒时间点")]
public IReadOnlyList<int> ReminderDaysBeforeExpiry { get; init; } = [7, 3, 1];
}
/// <summary>
/// 续费提醒处理结果。
/// </summary>
public sealed record ProcessRenewalRemindersResult
{
/// <summary>
/// 扫描到的候选订阅数量。
/// </summary>
public int CandidateCount { get; init; }
/// <summary>
/// 实际创建的提醒数量。
/// </summary>
public int CreatedReminderCount { get; init; }
}

View File

@@ -1,33 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 处理订阅到期:到期进入宽限期,宽限期结束自动暂停。
/// </summary>
public sealed record ProcessSubscriptionExpiryCommand : IRequest<ProcessSubscriptionExpiryResult>
{
/// <summary>
/// 宽限期天数。
/// </summary>
[Range(0, 365, ErrorMessage = "宽限期天数必须在 0~365 之间")]
public int GracePeriodDays { get; init; } = 7;
}
/// <summary>
/// 订阅到期处理结果。
/// </summary>
public sealed record ProcessSubscriptionExpiryResult
{
/// <summary>
/// 从 Active 进入宽限期的数量。
/// </summary>
public int EnteredGracePeriodCount { get; init; }
/// <summary>
/// 宽限期到期并暂停的数量。
/// </summary>
public int SuspendedCount { get; init; }
}

View File

@@ -1,28 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 更新订阅基础信息命令。
/// </summary>
public sealed record UpdateSubscriptionCommand : IRequest<SubscriptionDetailDto?>
{
/// <summary>
/// 订阅 ID从路由参数绑定
/// </summary>
[Required]
public long SubscriptionId { get; init; }
/// <summary>
/// 是否自动续费。
/// </summary>
public bool? AutoRenew { get; init; }
/// <summary>
/// 运营备注信息。
/// </summary>
[MaxLength(500)]
public string? Notes { get; init; }
}

View File

@@ -1,30 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 更新订阅状态命令。
/// </summary>
public sealed record UpdateSubscriptionStatusCommand : IRequest<SubscriptionDetailDto?>
{
/// <summary>
/// 订阅 ID从路由参数绑定
/// </summary>
[Required]
public long SubscriptionId { get; init; }
/// <summary>
/// 目标状态。
/// </summary>
[Required]
public SubscriptionStatus Status { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
[MaxLength(500)]
public string? Notes { get; init; }
}

View File

@@ -1,52 +0,0 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Subscriptions.Dto;
/// <summary>
/// 配额使用 DTO。
/// </summary>
public sealed record QuotaUsageDto
{
/// <summary>
/// 配额 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 配额类型。
/// </summary>
public TenantQuotaType QuotaType { get; init; }
/// <summary>
/// 配额上限。
/// </summary>
public decimal LimitValue { get; init; }
/// <summary>
/// 已使用量。
/// </summary>
public decimal UsedValue { get; init; }
/// <summary>
/// 使用率(百分比)。
/// </summary>
public decimal UsagePercentage => LimitValue > 0 ? Math.Round(UsedValue / LimitValue * 100, 2) : 0;
/// <summary>
/// 剩余额度。
/// </summary>
public decimal RemainingValue => Math.Max(0, LimitValue - UsedValue);
/// <summary>
/// 重置周期描述。
/// </summary>
public string? ResetCycle { get; init; }
/// <summary>
/// 最近一次重置时间。
/// </summary>
public DateTime? LastResetAt { get; init; }
}

View File

@@ -1,106 +0,0 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Subscriptions.Dto;
/// <summary>
/// 订阅详情 DTO。
/// </summary>
public sealed record SubscriptionDetailDto
{
/// <summary>
/// 订阅 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string TenantName { get; init; } = string.Empty;
/// <summary>
/// 租户编码。
/// </summary>
public string TenantCode { get; init; } = string.Empty;
/// <summary>
/// 当前套餐 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantPackageId { get; init; }
/// <summary>
/// 当前套餐信息。
/// </summary>
public TenantPackageDto? Package { get; init; }
/// <summary>
/// 排期套餐 ID下周期生效
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long? ScheduledPackageId { get; init; }
/// <summary>
/// 排期套餐信息。
/// </summary>
public TenantPackageDto? ScheduledPackage { get; init; }
/// <summary>
/// 订阅状态。
/// </summary>
public SubscriptionStatus Status { get; init; }
/// <summary>
/// 生效时间UTC
/// </summary>
public DateTime EffectiveFrom { get; init; }
/// <summary>
/// 到期时间UTC
/// </summary>
public DateTime EffectiveTo { get; init; }
/// <summary>
/// 下次计费时间。
/// </summary>
public DateTime? NextBillingDate { get; init; }
/// <summary>
/// 是否自动续费。
/// </summary>
public bool AutoRenew { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 配额使用情况列表。
/// </summary>
public IReadOnlyList<QuotaUsageDto> QuotaUsages { get; init; } = [];
/// <summary>
/// 订阅变更历史列表。
/// </summary>
public IReadOnlyList<SubscriptionHistoryDto> ChangeHistory { get; init; } = [];
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime? UpdatedAt { get; init; }
}

View File

@@ -1,80 +0,0 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Subscriptions.Dto;
/// <summary>
/// 订阅变更历史 DTO。
/// </summary>
public sealed record SubscriptionHistoryDto
{
/// <summary>
/// 历史记录 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 订阅 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantSubscriptionId { get; init; }
/// <summary>
/// 原套餐 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long FromPackageId { get; init; }
/// <summary>
/// 原套餐名称。
/// </summary>
public string FromPackageName { get; init; } = string.Empty;
/// <summary>
/// 新套餐 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long ToPackageId { get; init; }
/// <summary>
/// 新套餐名称。
/// </summary>
public string ToPackageName { get; init; } = string.Empty;
/// <summary>
/// 变更类型。
/// </summary>
public SubscriptionChangeType ChangeType { get; init; }
/// <summary>
/// 生效时间。
/// </summary>
public DateTime EffectiveFrom { get; init; }
/// <summary>
/// 到期时间。
/// </summary>
public DateTime EffectiveTo { get; init; }
/// <summary>
/// 相关费用。
/// </summary>
public decimal? Amount { get; init; }
/// <summary>
/// 币种。
/// </summary>
public string? Currency { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -1,95 +0,0 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Subscriptions.Dto;
/// <summary>
/// 订阅列表 DTO。
/// </summary>
public sealed record SubscriptionListDto
{
/// <summary>
/// 订阅 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string TenantName { get; init; } = string.Empty;
/// <summary>
/// 租户编码。
/// </summary>
public string TenantCode { get; init; } = string.Empty;
/// <summary>
/// 当前套餐 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantPackageId { get; init; }
/// <summary>
/// 当前套餐名称。
/// </summary>
public string PackageName { get; init; } = string.Empty;
/// <summary>
/// 排期套餐 ID下周期生效
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long? ScheduledPackageId { get; init; }
/// <summary>
/// 排期套餐名称。
/// </summary>
public string? ScheduledPackageName { get; init; }
/// <summary>
/// 订阅状态。
/// </summary>
public SubscriptionStatus Status { get; init; }
/// <summary>
/// 生效时间UTC
/// </summary>
public DateTime EffectiveFrom { get; init; }
/// <summary>
/// 到期时间UTC
/// </summary>
public DateTime EffectiveTo { get; init; }
/// <summary>
/// 下次计费时间。
/// </summary>
public DateTime? NextBillingDate { get; init; }
/// <summary>
/// 是否自动续费。
/// </summary>
public bool AutoRenew { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime? UpdatedAt { get; init; }
}

View File

@@ -1,135 +0,0 @@
using MediatR;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 批量延期订阅命令处理器。
/// </summary>
public sealed class BatchExtendSubscriptionsCommandHandler(
ISubscriptionRepository subscriptionRepository,
IIdGenerator idGenerator,
ILogger<BatchExtendSubscriptionsCommandHandler> logger)
: IRequestHandler<BatchExtendSubscriptionsCommand, BatchExtendResult>
{
/// <inheritdoc />
public async Task<BatchExtendResult> Handle(BatchExtendSubscriptionsCommand request, CancellationToken cancellationToken)
{
var successCount = 0;
var failures = new List<BatchFailureItem>();
// 验证参数
if (!request.DurationDays.HasValue && !request.DurationMonths.HasValue)
{
throw new InvalidOperationException("必须指定延期天数或延期月数");
}
// 计算延期时间
var extendDays = request.DurationDays ?? 0;
var extendMonths = request.DurationMonths ?? 0;
// 查询所有订阅
var subscriptions = await subscriptionRepository.FindByIdsAsync(
request.SubscriptionIds,
cancellationToken);
foreach (var subscriptionId in request.SubscriptionIds)
{
try
{
var subscription = subscriptions.FirstOrDefault(s => s.Id == subscriptionId);
if (subscription == null)
{
failures.Add(new BatchFailureItem
{
SubscriptionId = subscriptionId,
Reason = "订阅不存在"
});
continue;
}
// 记录原始到期时间
var originalEffectiveTo = subscription.EffectiveTo;
// 计算新的到期时间
var newEffectiveTo = subscription.EffectiveTo;
if (extendMonths > 0)
{
newEffectiveTo = newEffectiveTo.AddMonths(extendMonths);
}
if (extendDays > 0)
{
newEffectiveTo = newEffectiveTo.AddDays(extendDays);
}
subscription.EffectiveTo = newEffectiveTo;
// 更新备注
if (!string.IsNullOrWhiteSpace(request.Notes))
{
subscription.Notes = request.Notes;
}
// 记录变更历史
var history = new TenantSubscriptionHistory
{
Id = idGenerator.NextId(),
TenantId = subscription.TenantId,
TenantSubscriptionId = subscription.Id,
FromPackageId = subscription.TenantPackageId,
ToPackageId = subscription.TenantPackageId,
ChangeType = SubscriptionChangeType.Renew,
EffectiveFrom = originalEffectiveTo,
EffectiveTo = newEffectiveTo,
Amount = null,
Currency = null,
Notes = request.Notes ?? $"批量延期: {(extendMonths > 0 ? $"{extendMonths}" : "")}{(extendDays > 0 ? $"{extendDays}" : "")}"
};
await subscriptionRepository.AddHistoryAsync(history, cancellationToken);
await subscriptionRepository.UpdateAsync(subscription, cancellationToken);
successCount++;
}
catch (Exception ex)
{
logger.LogError(ex, "批量延期订阅失败: SubscriptionId={SubscriptionId}", subscriptionId);
failures.Add(new BatchFailureItem
{
SubscriptionId = subscriptionId,
Reason = $"处理失败: {ex.Message}"
});
}
}
// 记录操作日志
var operationLog = new OperationLog
{
Id = idGenerator.NextId(),
OperationType = "BatchExtend",
TargetType = "Subscription",
TargetIds = JsonSerializer.Serialize(request.SubscriptionIds),
Parameters = JsonSerializer.Serialize(new { request.DurationDays, request.DurationMonths, request.Notes }),
Result = JsonSerializer.Serialize(new { SuccessCount = successCount, FailureCount = failures.Count }),
Success = failures.Count == 0,
CreatedAt = DateTime.UtcNow
};
await subscriptionRepository.AddOperationLogAsync(operationLog, cancellationToken);
// 保存所有更改
await subscriptionRepository.SaveChangesAsync(cancellationToken);
return new BatchExtendResult
{
SuccessCount = successCount,
FailureCount = failures.Count,
Failures = failures
};
}
}

View File

@@ -1,104 +0,0 @@
using MediatR;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 批量发送续费提醒命令处理器。
/// </summary>
public sealed class BatchSendReminderCommandHandler(
ISubscriptionRepository subscriptionRepository,
IIdGenerator idGenerator,
ILogger<BatchSendReminderCommandHandler> logger)
: IRequestHandler<BatchSendReminderCommand, BatchSendReminderResult>
{
/// <inheritdoc />
public async Task<BatchSendReminderResult> Handle(BatchSendReminderCommand request, CancellationToken cancellationToken)
{
var successCount = 0;
var failures = new List<BatchFailureItem>();
// 查询所有订阅及租户信息
var subscriptions = await subscriptionRepository.FindByIdsWithTenantAsync(
request.SubscriptionIds,
cancellationToken);
foreach (var subscriptionId in request.SubscriptionIds)
{
try
{
var item = subscriptions.FirstOrDefault(s => s.Subscription.Id == subscriptionId);
if (item == null)
{
failures.Add(new BatchFailureItem
{
SubscriptionId = subscriptionId,
Reason = "订阅不存在"
});
continue;
}
// 创建通知记录
var notification = new TenantNotification
{
Id = idGenerator.NextId(),
TenantId = item.Subscription.TenantId,
Title = "续费提醒",
Message = request.ReminderContent,
Severity = TenantNotificationSeverity.Warning,
Channel = TenantNotificationChannel.InApp,
SentAt = DateTime.UtcNow,
ReadAt = null,
CreatedAt = DateTime.UtcNow
};
await subscriptionRepository.AddNotificationAsync(notification, cancellationToken);
successCount++;
logger.LogInformation(
"发送续费提醒: SubscriptionId={SubscriptionId}, TenantId={TenantId}, TenantName={TenantName}",
subscriptionId, item.Subscription.TenantId, item.Tenant.Name);
}
catch (Exception ex)
{
logger.LogError(ex, "发送续费提醒失败: SubscriptionId={SubscriptionId}", subscriptionId);
failures.Add(new BatchFailureItem
{
SubscriptionId = subscriptionId,
Reason = $"发送失败: {ex.Message}"
});
}
}
// 记录操作日志
var operationLog = new OperationLog
{
Id = idGenerator.NextId(),
OperationType = "BatchRemind",
TargetType = "Subscription",
TargetIds = JsonSerializer.Serialize(request.SubscriptionIds),
Parameters = JsonSerializer.Serialize(new { request.ReminderContent }),
Result = JsonSerializer.Serialize(new { SuccessCount = successCount, FailureCount = failures.Count }),
Success = failures.Count == 0,
CreatedAt = DateTime.UtcNow
};
await subscriptionRepository.AddOperationLogAsync(operationLog, cancellationToken);
// 保存所有更改
await subscriptionRepository.SaveChangesAsync(cancellationToken);
return new BatchSendReminderResult
{
SuccessCount = successCount,
FailureCount = failures.Count,
Failures = failures
};
}
}

View File

@@ -1,95 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
using TakeoutSaaS.Application.App.Subscriptions.Queries;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 变更套餐命令处理器。
/// </summary>
public sealed class ChangeSubscriptionPlanCommandHandler(
ISubscriptionRepository subscriptionRepository,
IIdGenerator idGenerator,
IMediator mediator)
: IRequestHandler<ChangeSubscriptionPlanCommand, SubscriptionDetailDto?>
{
/// <inheritdoc />
public async Task<SubscriptionDetailDto?> Handle(ChangeSubscriptionPlanCommand request, CancellationToken cancellationToken)
{
// 1. 查询订阅
var subscription = await subscriptionRepository.FindByIdAsync(
request.SubscriptionId,
cancellationToken);
if (subscription == null)
{
return null;
}
// 2. 记录原套餐ID
var previousPackageId = subscription.TenantPackageId;
// 3. 根据是否立即生效更新订阅
if (request.Immediate)
{
// 立即生效:直接更新当前套餐
subscription.TenantPackageId = request.TargetPackageId;
subscription.ScheduledPackageId = null;
}
else
{
// 下周期生效:设置排期套餐
subscription.ScheduledPackageId = request.TargetPackageId;
}
// 4. 更新备注
if (!string.IsNullOrWhiteSpace(request.Notes))
{
subscription.Notes = request.Notes;
}
// 5. 判断变更类型(升级或降级)
var fromPackage = await subscriptionRepository.FindPackageByIdAsync(previousPackageId, cancellationToken);
var toPackage = await subscriptionRepository.FindPackageByIdAsync(request.TargetPackageId, cancellationToken);
var changeType = SubscriptionChangeType.Upgrade;
if (fromPackage != null && toPackage != null)
{
// 简单根据价格判断升降级
if (toPackage.MonthlyPrice < fromPackage.MonthlyPrice)
{
changeType = SubscriptionChangeType.Downgrade;
}
}
// 6. 记录变更历史
var history = new TenantSubscriptionHistory
{
Id = idGenerator.NextId(),
TenantId = subscription.TenantId,
TenantSubscriptionId = subscription.Id,
FromPackageId = previousPackageId,
ToPackageId = request.TargetPackageId,
ChangeType = changeType,
EffectiveFrom = request.Immediate ? DateTime.UtcNow : subscription.EffectiveTo,
EffectiveTo = subscription.EffectiveTo,
Amount = null,
Currency = null,
Notes = request.Notes ?? (request.Immediate ? "套餐立即变更" : "套餐排期变更")
};
await subscriptionRepository.AddHistoryAsync(history, cancellationToken);
// 7. 保存更改
await subscriptionRepository.UpdateAsync(subscription, cancellationToken);
await subscriptionRepository.SaveChangesAsync(cancellationToken);
// 8. 返回更新后的详情
return await mediator.Send(new GetSubscriptionDetailQuery { SubscriptionId = subscription.Id }, cancellationToken);
}
}

View File

@@ -1,69 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
using TakeoutSaaS.Application.App.Subscriptions.Queries;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 延期订阅命令处理器。
/// </summary>
public sealed class ExtendSubscriptionCommandHandler(
ISubscriptionRepository subscriptionRepository,
IIdGenerator idGenerator,
IMediator mediator)
: IRequestHandler<ExtendSubscriptionCommand, SubscriptionDetailDto?>
{
/// <inheritdoc />
public async Task<SubscriptionDetailDto?> Handle(ExtendSubscriptionCommand request, CancellationToken cancellationToken)
{
// 1. 查询订阅
var subscription = await subscriptionRepository.FindByIdAsync(
request.SubscriptionId,
cancellationToken);
if (subscription == null)
{
return null;
}
// 2. 计算新的到期时间(从当前到期时间延长)
var originalEffectiveTo = subscription.EffectiveTo;
subscription.EffectiveTo = subscription.EffectiveTo.AddMonths(request.DurationMonths);
// 3. 更新备注
if (!string.IsNullOrWhiteSpace(request.Notes))
{
subscription.Notes = request.Notes;
}
// 4. 记录变更历史(使用 Renew 类型表示延期)
var history = new TenantSubscriptionHistory
{
Id = idGenerator.NextId(),
TenantId = subscription.TenantId,
TenantSubscriptionId = subscription.Id,
FromPackageId = subscription.TenantPackageId,
ToPackageId = subscription.TenantPackageId,
ChangeType = SubscriptionChangeType.Renew,
EffectiveFrom = originalEffectiveTo,
EffectiveTo = subscription.EffectiveTo,
Amount = null,
Currency = null,
Notes = request.Notes ?? $"延期 {request.DurationMonths} 个月"
};
await subscriptionRepository.AddHistoryAsync(history, cancellationToken);
// 5. 保存更改
await subscriptionRepository.UpdateAsync(subscription, cancellationToken);
await subscriptionRepository.SaveChangesAsync(cancellationToken);
// 6. 返回更新后的详情
return await mediator.Send(new GetSubscriptionDetailQuery { SubscriptionId = subscription.Id }, cancellationToken);
}
}

View File

@@ -1,138 +0,0 @@
using MediatR;
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)
: IRequestHandler<GetSubscriptionDetailQuery, SubscriptionDetailDto?>
{
/// <inheritdoc />
public async Task<SubscriptionDetailDto?> Handle(GetSubscriptionDetailQuery request, CancellationToken cancellationToken)
{
// 1. 查询订阅基础信息
var detail = await subscriptionRepository.GetDetailAsync(
request.SubscriptionId,
cancellationToken);
if (detail == null)
{
return null;
}
// 2. 查询配额使用情况
var quotaUsages = await subscriptionRepository.GetQuotaUsagesAsync(
detail.Subscription.TenantId,
cancellationToken);
var quotaUsageDtos = BuildQuotaUsageDtos(detail.Package, quotaUsages);
// 3. 查询订阅变更历史(关联套餐信息)
var histories = await subscriptionRepository.GetHistoryAsync(request.SubscriptionId, cancellationToken);
var historyDtos = histories.Select(h => new SubscriptionHistoryDto
{
Id = h.History.Id,
TenantSubscriptionId = h.History.TenantSubscriptionId,
FromPackageId = h.History.FromPackageId,
FromPackageName = h.FromPackageName,
ToPackageId = h.History.ToPackageId,
ToPackageName = h.ToPackageName,
ChangeType = h.History.ChangeType,
EffectiveFrom = h.History.EffectiveFrom,
EffectiveTo = h.History.EffectiveTo,
Amount = h.History.Amount,
Currency = h.History.Currency,
Notes = h.History.Notes,
CreatedAt = h.History.CreatedAt
}).ToList();
// 4. 构建返回结果
return new SubscriptionDetailDto
{
Id = detail.Subscription.Id,
TenantId = detail.Subscription.TenantId,
TenantName = detail.TenantName,
TenantCode = detail.TenantCode,
TenantPackageId = detail.Subscription.TenantPackageId,
Package = detail.Package?.ToDto(),
ScheduledPackageId = detail.Subscription.ScheduledPackageId,
ScheduledPackage = detail.ScheduledPackage?.ToDto(),
Status = detail.Subscription.Status,
EffectiveFrom = detail.Subscription.EffectiveFrom,
EffectiveTo = detail.Subscription.EffectiveTo,
NextBillingDate = detail.Subscription.NextBillingDate,
AutoRenew = detail.Subscription.AutoRenew,
Notes = detail.Subscription.Notes,
QuotaUsages = quotaUsageDtos,
ChangeHistory = historyDtos,
CreatedAt = detail.Subscription.CreatedAt,
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,61 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
using TakeoutSaaS.Application.App.Subscriptions.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 订阅分页查询处理器。
/// </summary>
public sealed class GetSubscriptionListQueryHandler(
ISubscriptionRepository subscriptionRepository)
: IRequestHandler<GetSubscriptionListQuery, PagedResult<SubscriptionListDto>>
{
/// <inheritdoc />
public async Task<PagedResult<SubscriptionListDto>> Handle(GetSubscriptionListQuery request, CancellationToken cancellationToken)
{
// 1. 构建查询过滤条件
var filter = new SubscriptionSearchFilter
{
Status = request.Status,
TenantPackageId = request.TenantPackageId,
TenantId = request.TenantId,
TenantKeyword = request.TenantKeyword,
ExpiringWithinDays = request.ExpiringWithinDays,
AutoRenew = request.AutoRenew,
Page = request.Page,
PageSize = request.PageSize
};
// 2. 执行分页查询
var (items, total) = await subscriptionRepository.SearchPagedAsync(
filter,
cancellationToken);
// 3. 映射为 DTO
var dtos = items.Select(x => new SubscriptionListDto
{
Id = x.Subscription.Id,
TenantId = x.Subscription.TenantId,
TenantName = x.TenantName,
TenantCode = x.TenantCode,
TenantPackageId = x.Subscription.TenantPackageId,
PackageName = x.PackageName,
ScheduledPackageId = x.Subscription.ScheduledPackageId,
ScheduledPackageName = x.ScheduledPackageName,
Status = x.Subscription.Status,
EffectiveFrom = x.Subscription.EffectiveFrom,
EffectiveTo = x.Subscription.EffectiveTo,
NextBillingDate = x.Subscription.NextBillingDate,
AutoRenew = x.Subscription.AutoRenew,
Notes = x.Subscription.Notes,
CreatedAt = x.Subscription.CreatedAt,
UpdatedAt = x.Subscription.UpdatedAt
}).ToList();
// 4. 返回分页结果
return new PagedResult<SubscriptionListDto>(dtos, request.Page, request.PageSize, total);
}
}

View File

@@ -1,137 +0,0 @@
using MediatR;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 自动续费处理器:生成续费账单(幂等)。
/// </summary>
public sealed class ProcessAutoRenewalCommandHandler(
ISubscriptionRepository subscriptionRepository,
ITenantBillingRepository billingRepository,
IIdGenerator idGenerator,
ILogger<ProcessAutoRenewalCommandHandler> logger)
: IRequestHandler<ProcessAutoRenewalCommand, ProcessAutoRenewalResult>
{
/// <inheritdoc />
public async Task<ProcessAutoRenewalResult> Handle(ProcessAutoRenewalCommand request, CancellationToken cancellationToken)
{
// 1. 计算续费阈值时间
var now = DateTime.UtcNow;
var renewalThreshold = now.AddDays(request.RenewalDaysBeforeExpiry);
// 2. 查询候选订阅(含套餐)
var candidates = await subscriptionRepository.FindAutoRenewalCandidatesAsync(
now,
renewalThreshold,
cancellationToken);
var createdBillCount = 0;
// 3. 遍历候选订阅,生成账单
foreach (var candidate in candidates)
{
// 3.1 幂等校验:同一周期开始时间只允许存在一张未取消账单
var periodStart = candidate.Subscription.EffectiveTo;
var exists = await billingRepository.ExistsNotCancelledByPeriodStartAsync(
candidate.Subscription.TenantId,
periodStart,
cancellationToken);
if (exists)
{
logger.LogInformation(
"自动续费:租户 {TenantId} 订阅 {SubscriptionId} 已存在周期 {PeriodStart} 的续费账单,跳过",
candidate.Subscription.TenantId,
candidate.Subscription.Id,
periodStart);
continue;
}
// 3.2 计算续费周期(月)
var durationMonths = CalculateDurationMonths(candidate.Subscription.EffectiveFrom, candidate.Subscription.EffectiveTo);
if (durationMonths <= 0)
{
durationMonths = 1;
}
// 3.3 计算账单周期与金额
var periodEnd = periodStart.AddMonths(durationMonths);
var amountDue = CalculateRenewalAmount(candidate.Package, durationMonths);
// 3.4 生成账单Pending
var statementNo = $"BILL-{now:yyyyMMddHHmmss}-{candidate.Subscription.TenantId}-{candidate.Subscription.Id}";
var lineItemsJson = JsonSerializer.Serialize(new
{
PackageName = candidate.Package.Name,
RenewalMonths = durationMonths,
SubscriptionId = candidate.Subscription.Id
});
await billingRepository.AddAsync(new TenantBillingStatement
{
Id = idGenerator.NextId(),
TenantId = candidate.Subscription.TenantId,
StatementNo = statementNo,
PeriodStart = periodStart,
PeriodEnd = periodEnd,
AmountDue = amountDue,
AmountPaid = 0,
DueDate = periodStart.AddDays(-1),
LineItemsJson = lineItemsJson,
CreatedAt = now
}, cancellationToken);
createdBillCount++;
logger.LogInformation(
"自动续费:为租户 {TenantId} 订阅 {SubscriptionId} 生成账单 {StatementNo},金额 {AmountDue},周期 {PeriodStart}~{PeriodEnd}",
candidate.Subscription.TenantId,
candidate.Subscription.Id,
statementNo,
amountDue,
periodStart,
periodEnd);
}
// 4. 保存账单变更
if (createdBillCount > 0)
{
await billingRepository.SaveChangesAsync(cancellationToken);
}
return new ProcessAutoRenewalResult
{
CandidateCount = candidates.Count,
CreatedBillCount = createdBillCount
};
}
private static int CalculateDurationMonths(DateTime effectiveFrom, DateTime effectiveTo)
{
// 1. 以年月差作为周期(月),兼容“按月续费”模型
var months = (effectiveTo.Year - effectiveFrom.Year) * 12 + effectiveTo.Month - effectiveFrom.Month;
// 2. 对不足 1 个月的情况兜底为 1
return months <= 0 ? 1 : months;
}
private static decimal CalculateRenewalAmount(TenantPackage package, int durationMonths)
{
// 1. 优先使用年付价(按整年计费),剩余月份按月付价补齐
var monthlyPrice = package.MonthlyPrice ?? 0m;
var yearlyPrice = package.YearlyPrice;
if (yearlyPrice is null || durationMonths < 12)
{
return monthlyPrice * durationMonths;
}
// 2. 按年 + 月组合计算金额
var years = durationMonths / 12;
var remainingMonths = durationMonths % 12;
return yearlyPrice.Value * years + monthlyPrice * remainingMonths;
}
}

View File

@@ -1,117 +0,0 @@
using MediatR;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 续费提醒处理器:在到期前 7/3/1 天等时间点发送站内提醒(幂等)。
/// </summary>
public sealed class ProcessRenewalRemindersCommandHandler(
ISubscriptionRepository subscriptionRepository,
ITenantNotificationRepository notificationRepository,
IIdGenerator idGenerator,
ILogger<ProcessRenewalRemindersCommandHandler> logger)
: IRequestHandler<ProcessRenewalRemindersCommand, ProcessRenewalRemindersResult>
{
private const string ReminderTitle = "订阅续费提醒";
/// <inheritdoc />
public async Task<ProcessRenewalRemindersResult> Handle(ProcessRenewalRemindersCommand request, CancellationToken cancellationToken)
{
// 1. 读取提醒配置
var now = DateTime.UtcNow;
var candidateCount = 0;
var createdReminderCount = 0;
var dedupeWindowStart = now.AddHours(-24);
// 2. 按提醒时间点扫描到期订阅
foreach (var daysBeforeExpiry in request.ReminderDaysBeforeExpiry.Distinct().OrderByDescending(x => x))
{
// 2.1 计算目标日期区间(按天匹配)
var targetDate = now.AddDays(daysBeforeExpiry);
var startOfDay = targetDate.Date;
var endOfDay = startOfDay.AddDays(1);
// 2.2 查询候选订阅(活跃 + 未开自动续费 + 到期在当日)
var candidates = await subscriptionRepository.FindRenewalReminderCandidatesAsync(
startOfDay,
endOfDay,
cancellationToken);
candidateCount += candidates.Count;
foreach (var item in candidates)
{
// 2.3 幂等:同一订阅 + 同一天数提醒,在 24 小时内只发送一次
var metadataJson = BuildReminderMetadata(item.Subscription.Id, daysBeforeExpiry, item.Subscription.EffectiveTo);
var alreadySent = await notificationRepository.ExistsByMetadataAsync(
item.Subscription.TenantId,
ReminderTitle,
metadataJson,
dedupeWindowStart,
cancellationToken);
if (alreadySent)
{
continue;
}
// 2.4 构造提醒内容并入库
var notification = new TenantNotification
{
Id = idGenerator.NextId(),
TenantId = item.Subscription.TenantId,
Title = ReminderTitle,
Message = $"您的订阅套餐「{item.Package.Name}」将在 {daysBeforeExpiry} 天内到期(到期时间:{item.Subscription.EffectiveTo:yyyy-MM-dd HH:mm}),请及时续费以免影响使用。",
Severity = daysBeforeExpiry <= 1
? TenantNotificationSeverity.Critical
: TenantNotificationSeverity.Warning,
Channel = TenantNotificationChannel.InApp,
SentAt = now,
ReadAt = null,
MetadataJson = metadataJson,
CreatedAt = now
};
await notificationRepository.AddAsync(notification, cancellationToken);
createdReminderCount++;
logger.LogInformation(
"续费提醒TenantId={TenantId}, TenantName={TenantName}, SubscriptionId={SubscriptionId}, DaysBeforeExpiry={DaysBeforeExpiry}",
item.Tenant.Id,
item.Tenant.Name,
item.Subscription.Id,
daysBeforeExpiry);
}
}
// 3. 保存变更
if (createdReminderCount > 0)
{
await notificationRepository.SaveChangesAsync(cancellationToken);
}
return new ProcessRenewalRemindersResult
{
CandidateCount = candidateCount,
CreatedReminderCount = createdReminderCount
};
}
private static string BuildReminderMetadata(long subscriptionId, int daysBeforeExpiry, DateTime effectiveTo)
{
// 1. 使用稳定字段顺序的 JSON 作为幂等键
return JsonSerializer.Serialize(new
{
Type = "RenewalReminder",
SubscriptionId = subscriptionId,
DaysBeforeExpiry = daysBeforeExpiry,
EffectiveTo = effectiveTo.ToString("O")
});
}
}

View File

@@ -1,63 +0,0 @@
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 订阅到期处理器:自动进入宽限期并在宽限期结束后暂停。
/// </summary>
public sealed class ProcessSubscriptionExpiryCommandHandler(
ISubscriptionRepository subscriptionRepository,
ILogger<ProcessSubscriptionExpiryCommandHandler> logger)
: IRequestHandler<ProcessSubscriptionExpiryCommand, ProcessSubscriptionExpiryResult>
{
/// <inheritdoc />
public async Task<ProcessSubscriptionExpiryResult> Handle(ProcessSubscriptionExpiryCommand request, CancellationToken cancellationToken)
{
// 1. 查询到期订阅
var now = DateTime.UtcNow;
var expiredActive = await subscriptionRepository.FindExpiredActiveSubscriptionsAsync(
now,
cancellationToken);
var gracePeriodExpired = await subscriptionRepository.FindGracePeriodExpiredSubscriptionsAsync(
now,
request.GracePeriodDays,
cancellationToken);
// 2. 更新订阅状态
foreach (var subscription in expiredActive)
{
subscription.Status = SubscriptionStatus.GracePeriod;
await subscriptionRepository.UpdateAsync(subscription, cancellationToken);
}
// 3. 宽限期到期自动暂停
foreach (var subscription in gracePeriodExpired)
{
subscription.Status = SubscriptionStatus.Suspended;
await subscriptionRepository.UpdateAsync(subscription, cancellationToken);
}
// 4. 保存变更
var totalChanged = expiredActive.Count + gracePeriodExpired.Count;
if (totalChanged > 0)
{
await subscriptionRepository.SaveChangesAsync(cancellationToken);
}
logger.LogInformation(
"订阅到期处理完成:进入宽限期 {EnteredGracePeriodCount},暂停 {SuspendedCount},宽限期天数 {GracePeriodDays}",
expiredActive.Count,
gracePeriodExpired.Count,
request.GracePeriodDays);
return new ProcessSubscriptionExpiryResult
{
EnteredGracePeriodCount = expiredActive.Count,
SuspendedCount = gracePeriodExpired.Count
};
}
}

View File

@@ -1,48 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
using TakeoutSaaS.Application.App.Subscriptions.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 更新订阅基础信息命令处理器。
/// </summary>
public sealed class UpdateSubscriptionCommandHandler(
ISubscriptionRepository subscriptionRepository,
IMediator mediator)
: IRequestHandler<UpdateSubscriptionCommand, SubscriptionDetailDto?>
{
/// <inheritdoc />
public async Task<SubscriptionDetailDto?> Handle(UpdateSubscriptionCommand request, CancellationToken cancellationToken)
{
// 1. 查询订阅
var subscription = await subscriptionRepository.FindByIdAsync(
request.SubscriptionId,
cancellationToken);
if (subscription == null)
{
return null;
}
// 2. 更新字段
if (request.AutoRenew.HasValue)
{
subscription.AutoRenew = request.AutoRenew.Value;
}
if (request.Notes != null)
{
subscription.Notes = request.Notes;
}
// 3. 保存更改
await subscriptionRepository.UpdateAsync(subscription, cancellationToken);
await subscriptionRepository.SaveChangesAsync(cancellationToken);
// 4. 返回更新后的详情
return await mediator.Send(new GetSubscriptionDetailQuery { SubscriptionId = subscription.Id }, cancellationToken);
}
}

View File

@@ -1,46 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
using TakeoutSaaS.Application.App.Subscriptions.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 更新订阅状态命令处理器。
/// </summary>
public sealed class UpdateSubscriptionStatusCommandHandler(
ISubscriptionRepository subscriptionRepository,
IMediator mediator)
: IRequestHandler<UpdateSubscriptionStatusCommand, SubscriptionDetailDto?>
{
/// <inheritdoc />
public async Task<SubscriptionDetailDto?> Handle(UpdateSubscriptionStatusCommand request, CancellationToken cancellationToken)
{
// 1. 查询订阅
var subscription = await subscriptionRepository.FindByIdAsync(
request.SubscriptionId,
cancellationToken);
if (subscription == null)
{
return null;
}
// 2. 更新状态
subscription.Status = request.Status;
// 3. 更新备注
if (!string.IsNullOrWhiteSpace(request.Notes))
{
subscription.Notes = request.Notes;
}
// 4. 保存更改
await subscriptionRepository.UpdateAsync(subscription, cancellationToken);
await subscriptionRepository.SaveChangesAsync(cancellationToken);
// 5. 返回更新后的详情
return await mediator.Send(new GetSubscriptionDetailQuery { SubscriptionId = subscription.Id }, cancellationToken);
}
}

View File

@@ -1,15 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
namespace TakeoutSaaS.Application.App.Subscriptions.Queries;
/// <summary>
/// 查询订阅详情(含套餐信息、配额使用、变更历史)。
/// </summary>
public sealed record GetSubscriptionDetailQuery : IRequest<SubscriptionDetailDto?>
{
/// <summary>
/// 订阅 ID。
/// </summary>
public long SubscriptionId { get; init; }
}

View File

@@ -1,52 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Subscriptions.Queries;
/// <summary>
/// 订阅分页查询。
/// </summary>
public sealed record GetSubscriptionListQuery : IRequest<PagedResult<SubscriptionListDto>>
{
/// <summary>
/// 订阅状态(精确匹配)。
/// </summary>
public SubscriptionStatus? Status { get; init; }
/// <summary>
/// 套餐 ID精确匹配
/// </summary>
public long? TenantPackageId { get; init; }
/// <summary>
/// 租户 ID精确匹配
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 租户关键词(名称或编码模糊匹配)。
/// </summary>
public string? TenantKeyword { get; init; }
/// <summary>
/// 到期时间筛选:未来 N 天内到期。
/// </summary>
public int? ExpiringWithinDays { get; init; }
/// <summary>
/// 是否自动续费筛选。
/// </summary>
public bool? AutoRenew { get; init; }
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页大小。
/// </summary>
public int PageSize { get; init; } = 20;
}