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; /// /// 续费提醒处理器:在到期前 7/3/1 天等时间点发送站内提醒(幂等)。 /// public sealed class ProcessRenewalRemindersCommandHandler( ISubscriptionRepository subscriptionRepository, ITenantNotificationRepository notificationRepository, IIdGenerator idGenerator, ILogger logger) : IRequestHandler { private const string ReminderTitle = "订阅续费提醒"; /// public async Task 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") }); } }