118 lines
4.7 KiB
C#
118 lines
4.7 KiB
C#
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")
|
||
});
|
||
}
|
||
}
|