Files
TakeoutSaaS.AdminApi/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessRenewalRemindersCommandHandler.cs

118 lines
4.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
});
}
}